diff --git a/Discord.Net.sln b/Discord.Net.sln index 5c8ec796e..11960606b 100644 --- a/Discord.Net.sln +++ b/Discord.Net.sln @@ -3,24 +3,24 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.Tests", "test\Discord.Net.Tests\Discord.Net.Tests.csproj", "{855D6B1D-847B-42DA-BE6A-23683EA89511}" -EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Discord.Net", "src\Discord.Net\Discord.Net.xproj", "{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Discord.Net.Commands", "src\Discord.Net.Commands\Discord.Net.Commands.xproj", "{078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {855D6B1D-847B-42DA-BE6A-23683EA89511}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {855D6B1D-847B-42DA-BE6A-23683EA89511}.Debug|Any CPU.Build.0 = Debug|Any CPU - {855D6B1D-847B-42DA-BE6A-23683EA89511}.Release|Any CPU.ActiveCfg = Release|Any CPU - {855D6B1D-847B-42DA-BE6A-23683EA89511}.Release|Any CPU.Build.0 = Release|Any CPU {91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Release|Any CPU.Build.0 = Release|Any CPU + {078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 342dd011c..4fd77c11b 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,21 @@ # Discord.Net v1.0.0-dev +[![NuGet Pre Release](https://img.shields.io/nuget/vpre/Discord.Net.svg?maxAge=2592000?style=plastic)](https://www.nuget.org/packages/Discord.Net) [![AppVeyor](https://img.shields.io/appveyor/ci/foxbot/discord-net.svg?maxAge=2592000?style=plastic)](https://ci.appveyor.com/project/foxbot/discord-net/) [![Discord](https://discordapp.com/api/servers/81384788765712384/widget.png)](https://discord.gg/0SBTUU1wZTYLhAAW) [![NuGet Pre Release](https://img.shields.io/nuget/vpre/Discord.Net.svg?maxAge=2592000?style=plastic)](https://www.nuget.org/packages/Discord.Net) [![AppVeyor](https://img.shields.io/appveyor/ci/foxbot/discord-net.svg?maxAge=2592000?style=plastic)](https://ci.appveyor.com/project/foxbot/discord-net/) [![Discord](https://discordapp.com/api/servers/81384788765712384/widget.png)](https://discord.gg/0SBTUU1wZTYLhAAW) Discord.Net is an API wrapper for [Discord](http://discordapp.com) written in C#. -Check out the [documentation](https://discordnet.readthedocs.org/en/latest/) or join the [Discord API Chat](https://discord.gg/0SBTUU1wZTVjAMPx). - - -## Installing - -**NuGet is not up to date with 1.0.0-dev.** - -You can download Discord.Net and its extensions from NuGet: +### Installation +You can download Discord.Net and its command extension from NuGet: - [Discord.Net](https://www.nuget.org/packages/Discord.Net/) - [Discord.Net.Commands](https://www.nuget.org/packages/Discord.Net.Commands/) -- [Discord.Net.Modules](https://www.nuget.org/packages/Discord.Net.Modules/) -- [Discord.Net.Audio](https://www.nuget.org/packages/Discord.Net.Audio/) ### Compiling -In order to compile Discord.Net, you require at least the following: -- [Visual Studio 2015](https://www.visualstudio.com/downloads/download-visual-studio-vs) -- [Visual Studio 2015 Update 2](https://www.visualstudio.com/en-us/news/vs2015-update2-vs.aspx) -- [Visual Studio .Net Core Plugin](https://www.microsoft.com/net/core#windows) -- NuGet 3.3+ (available through Visual Studio) \ No newline at end of file +In order to compile Discord.Net, you require the following: + +#### Visual Studio 2015 +- [VS2015 Update 3](https://www.microsoft.com/net/core#windows) +- [.Net Core 1.0 VS Plugin](https://www.microsoft.com/net/core#windows) + +#### CLI +- [.Net Core 1.0 SDK](https://www.microsoft.com/net/core) diff --git a/global.json b/global.json index 7ee23dc6a..9a66d5edc 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,3 @@ { - "projects": [ "src", "test" ], - "sdk": { - "version": "1.0.0-preview1-002702" - } + "projects": [ "src", "test" ] } diff --git a/src/Discord.Net.Commands/Attributes/CommandAttribute.cs b/src/Discord.Net.Commands/Attributes/CommandAttribute.cs new file mode 100644 index 000000000..014668405 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/CommandAttribute.cs @@ -0,0 +1,19 @@ +using System; + +namespace Discord.Commands +{ + [AttributeUsage(AttributeTargets.Method)] + public class CommandAttribute : Attribute + { + public string Text { get; } + + public CommandAttribute() + { + Text = null; + } + public CommandAttribute(string text) + { + Text = text; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/DescriptionAttribute.cs b/src/Discord.Net.Commands/Attributes/DescriptionAttribute.cs new file mode 100644 index 000000000..736e9720b --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/DescriptionAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace Discord.Commands +{ + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter)] + public class DescriptionAttribute : Attribute + { + public string Text { get; } + public DescriptionAttribute(string text) + { + Text = text; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/GroupAttribute.cs b/src/Discord.Net.Commands/Attributes/GroupAttribute.cs new file mode 100644 index 000000000..3521f3f4f --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/GroupAttribute.cs @@ -0,0 +1,18 @@ +using System; + +namespace Discord.Commands +{ + [AttributeUsage(AttributeTargets.Class)] + public class GroupAttribute : Attribute + { + public string Prefix { get; } + public GroupAttribute() + { + Prefix = null; + } + public GroupAttribute(string prefix) + { + Prefix = prefix; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/ModuleAttribute.cs b/src/Discord.Net.Commands/Attributes/ModuleAttribute.cs new file mode 100644 index 000000000..59e6a6aca --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/ModuleAttribute.cs @@ -0,0 +1,18 @@ +using System; + +namespace Discord.Commands +{ + [AttributeUsage(AttributeTargets.Class)] + public class ModuleAttribute : Attribute + { + public string Prefix { get; } + public ModuleAttribute() + { + Prefix = null; + } + public ModuleAttribute(string prefix) + { + Prefix = prefix; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/UnparsedAttribute.cs b/src/Discord.Net.Commands/Attributes/UnparsedAttribute.cs new file mode 100644 index 000000000..9440b78af --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/UnparsedAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace Discord.Commands +{ + [AttributeUsage(AttributeTargets.Parameter)] + public class UnparsedAttribute : Attribute + { + } +} diff --git a/src/Discord.Net.Commands/Command.cs b/src/Discord.Net.Commands/Command.cs new file mode 100644 index 000000000..f9578d154 --- /dev/null +++ b/src/Discord.Net.Commands/Command.cs @@ -0,0 +1,124 @@ +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 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(Module module, object instance, CommandAttribute attribute, MethodInfo methodInfo, string groupPrefix) + { + Module = module; + _instance = instance; + + Name = methodInfo.Name; + Text = groupPrefix + attribute.Text; + + var description = methodInfo.GetCustomAttribute(); + if (description != null) + Description = description.Text; + + Parameters = BuildParameters(methodInfo); + _action = BuildAction(methodInfo); + } + + public async Task Parse(IMessage msg, SearchResult searchResult) + { + if (!searchResult.IsSuccess) + return ParseResult.FromError(searchResult); + + return await CommandParser.ParseArgs(this, msg, searchResult.Text.Substring(Text.Length), 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 IReadOnlyList BuildParameters(MethodInfo methodInfo) + { + 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($"{type.FullName} is not supported as a command parameter, are you missing a TypeReader?"); + + 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) + { + if (methodInfo.ReturnType != typeof(Task)) + throw new InvalidOperationException("Commands must return a non-generic Task."); + + //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..e5b272ebe --- /dev/null +++ b/src/Discord.Net.Commands/CommandParameter.cs @@ -0,0 +1,35 @@ +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; + Name = name; + 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..262d3cb58 --- /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 && curPos != endPos) + { + 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 != null && 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 new file mode 100644 index 000000000..a3400a465 --- /dev/null +++ b/src/Discord.Net.Commands/CommandService.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + public class CommandService + { + private readonly SemaphoreSlim _moduleLock; + private readonly ConcurrentDictionary _modules; + private readonly ConcurrentDictionary _typeReaders; + private readonly CommandMap _map; + + public IEnumerable Modules => _modules.Select(x => x.Value); + public IEnumerable Commands => _modules.SelectMany(x => x.Value.Commands); + + public CommandService() + { + _moduleLock = new SemaphoreSlim(1, 1); + _modules = new ConcurrentDictionary(); + _map = new CommandMap(); + _typeReaders = new ConcurrentDictionary + { + [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 moduleInstance) + { + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + if (_modules.ContainsKey(moduleInstance)) + throw new ArgumentException($"This module has already been loaded."); + + var typeInfo = moduleInstance.GetType().GetTypeInfo(); + var moduleAttr = typeInfo.GetCustomAttribute(); + if (moduleAttr == null) + throw new ArgumentException($"Modules must be marked with ModuleAttribute."); + + return LoadInternal(moduleInstance, moduleAttr, typeInfo); + } + finally + { + _moduleLock.Release(); + } + } + private Module LoadInternal(object moduleInstance, ModuleAttribute moduleAttr, TypeInfo typeInfo) + { + var loadedModule = new Module(this, moduleInstance, moduleAttr, typeInfo); + _modules[moduleInstance] = loadedModule; + + foreach (var cmd in loadedModule.Commands) + _map.AddCommand(cmd); + + return loadedModule; + } + public async Task> LoadAssembly(Assembly assembly) + { + var modules = ImmutableArray.CreateBuilder(); + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + foreach (var type in assembly.ExportedTypes) + { + var typeInfo = type.GetTypeInfo(); + var moduleAttr = typeInfo.GetCustomAttribute(); + if (moduleAttr != null) + { + var moduleInstance = ReflectionUtils.CreateObject(typeInfo); + modules.Add(LoadInternal(moduleInstance, moduleAttr, typeInfo)); + } + } + return modules.ToImmutable(); + } + finally + { + _moduleLock.Release(); + } + } + + public async Task Unload(Module module) + { + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + return UnloadInternal(module.Instance); + } + finally + { + _moduleLock.Release(); + } + } + public async Task Unload(object moduleInstance) + { + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + return UnloadInternal(moduleInstance); + } + finally + { + _moduleLock.Release(); + } + } + private bool UnloadInternal(object module) + { + Module unloadedModule; + if (_modules.TryRemove(module, out unloadedModule)) + { + foreach (var cmd in unloadedModule.Commands) + _map.RemoveCommand(cmd); + return true; + } + else + return false; + } + + public SearchResult Search(IMessage message, int argPos) => Search(message, message.RawText.Substring(argPos)); + public SearchResult Search(IMessage message, string input) + { + string lowerInput = input.ToLowerInvariant(); + var matches = _map.GetCommands(input).ToImmutableArray(); + + if (matches.Length > 0) + return SearchResult.FromSuccess(input, matches); + else + return SearchResult.FromError(CommandError.UnknownCommand, "Unknown command."); + } + + public Task Execute(IMessage message, int argPos) => Execute(message, message.RawText.Substring(argPos)); + public async Task Execute(IMessage message, string input) + { + var searchResult = Search(message, input); + if (!searchResult.IsSuccess) + return searchResult; + + var commands = searchResult.Commands; + for (int i = commands.Count - 1; i >= 0; i--) + { + var parseResult = await commands[i].Parse(message, searchResult); + if (!parseResult.IsSuccess) + continue; + var executeResult = await commands[i].Execute(message, parseResult); + return executeResult; + } + + return ParseResult.FromError(CommandError.ParseFailed, "This input does not match any overload."); + } + } +} diff --git a/src/Discord.Net.Commands/Discord.Net.Commands.xproj b/src/Discord.Net.Commands/Discord.Net.Commands.xproj new file mode 100644 index 000000000..597faf69c --- /dev/null +++ b/src/Discord.Net.Commands/Discord.Net.Commands.xproj @@ -0,0 +1,19 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 078dd7e6-943d-4d09-afc2-d2ba58b76c9c + Discord.Commands + .\obj + .\bin\ + v4.5.2 + + + 2.0 + + + \ No newline at end of file diff --git a/src/Discord.Net.Commands/Extensions/MessageExtensions.cs b/src/Discord.Net.Commands/Extensions/MessageExtensions.cs new file mode 100644 index 000000000..b2e3e173c --- /dev/null +++ b/src/Discord.Net.Commands/Extensions/MessageExtensions.cs @@ -0,0 +1,44 @@ +namespace Discord.Commands +{ + public static class MessageExtensions + { + public static bool HasCharPrefix(this IMessage msg, char c, ref int argPos) + { + var text = msg.RawText; + if (text.Length > 0 && text[0] == c) + { + argPos = 1; + return true; + } + return false; + } + public static bool HasStringPrefix(this IMessage msg, string str, ref int argPos) + { + var text = msg.RawText; + //str = str + ' '; + if (text.StartsWith(str)) + { + argPos = str.Length; + return true; + } + return false; + } + public static bool HasMentionPrefix(this IMessage msg, IUser user, ref int argPos) + { + var text = msg.RawText; + string mention = user.Mention + ' '; + if (text.StartsWith(mention)) + { + argPos = mention.Length; + return true; + } + string nickMention = user.NicknameMention + ' '; + if (text.StartsWith(mention)) + { + argPos = nickMention.Length; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.Commands/Map/CommandMap.cs b/src/Discord.Net.Commands/Map/CommandMap.cs new file mode 100644 index 000000000..0f719d56d --- /dev/null +++ b/src/Discord.Net.Commands/Map/CommandMap.cs @@ -0,0 +1,76 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Commands +{ + internal class CommandMap + { + private readonly ConcurrentDictionary _nodes; + + public CommandMap() + { + _nodes = new ConcurrentDictionary(); + } + + public void AddCommand(Command command) + { + string text = command.Text; + int nextSpace = text.IndexOf(' '); + string name; + + lock (this) + { + if (nextSpace == -1) + name = command.Text; + else + name = command.Text.Substring(0, nextSpace); + var nextNode = _nodes.GetOrAdd(name, x => new CommandMapNode(x)); + nextNode.AddCommand(nextSpace == -1 ? "" : text, nextSpace + 1, command); + } + } + public void RemoveCommand(Command command) + { + string text = command.Text; + int nextSpace = text.IndexOf(' '); + string name; + + lock (this) + { + if (nextSpace == -1) + name = command.Text; + else + name = command.Text.Substring(0, nextSpace); + + CommandMapNode nextNode; + if (_nodes.TryGetValue(name, out nextNode)) + { + nextNode.AddCommand(nextSpace == -1 ? "" : text, nextSpace + 1, command); + if (nextNode.IsEmpty) + _nodes.TryRemove(name, out nextNode); + } + } + } + + public IEnumerable GetCommands(string text) + { + int nextSpace = text.IndexOf(' '); + string name; + + lock (this) + { + if (nextSpace == -1) + name = text; + else + name = text.Substring(0, nextSpace); + + CommandMapNode nextNode; + if (_nodes.TryGetValue(name, out nextNode)) + return nextNode.GetCommands(text, nextSpace + 1); + else + return Enumerable.Empty(); + } + } + } +} diff --git a/src/Discord.Net.Commands/Map/CommandMapNode.cs b/src/Discord.Net.Commands/Map/CommandMapNode.cs new file mode 100644 index 000000000..1ce0b4724 --- /dev/null +++ b/src/Discord.Net.Commands/Map/CommandMapNode.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Commands +{ + internal class CommandMapNode + { + private readonly ConcurrentDictionary _nodes; + private readonly string _name; + private ImmutableArray _commands; + + public bool IsEmpty => _commands.Length == 0 && _nodes.Count == 0; + + public CommandMapNode(string name) + { + _name = name; + _nodes = new ConcurrentDictionary(); + _commands = ImmutableArray.Create(); + } + + public void AddCommand(string text, int index, Command command) + { + int nextSpace = text.IndexOf(' ', index); + string name; + + lock (this) + { + if (text == "") + _commands = _commands.Add(command); + else + { + if (nextSpace == -1) + name = text.Substring(index); + else + name = text.Substring(index, nextSpace - index); + + var nextNode = _nodes.GetOrAdd(name, x => new CommandMapNode(x)); + nextNode.AddCommand(nextSpace == -1 ? "" : text, nextSpace + 1, command); + } + } + } + public void RemoveCommand(string text, int index, Command command) + { + int nextSpace = text.IndexOf(' ', index); + string name; + + lock (this) + { + if (text == "") + _commands = _commands.Remove(command); + else + { + if (nextSpace == -1) + name = text.Substring(index); + else + name = text.Substring(index, nextSpace - index); + + CommandMapNode nextNode; + if (_nodes.TryGetValue(name, out nextNode)) + { + nextNode.RemoveCommand(nextSpace == -1 ? "" : text, nextSpace + 1, command); + if (nextNode.IsEmpty) + _nodes.TryRemove(name, out nextNode); + } + } + } + } + + public IEnumerable GetCommands(string text, int index) + { + int nextSpace = text.IndexOf(' ', index); + string name; + + var commands = _commands; + for (int i = 0; i < commands.Length; i++) + yield return _commands[i]; + + if (text != "") + { + if (nextSpace == -1) + name = text.Substring(index); + else + name = text.Substring(index, nextSpace - index); + + CommandMapNode nextNode; + if (_nodes.TryGetValue(name, out nextNode)) + { + foreach (var cmd in nextNode.GetCommands(nextSpace == -1 ? "" : text, nextSpace + 1)) + yield return cmd; + } + } + } + } +} diff --git a/src/Discord.Net.Commands/Module.cs b/src/Discord.Net.Commands/Module.cs new file mode 100644 index 000000000..ea6e29c28 --- /dev/null +++ b/src/Discord.Net.Commands/Module.cs @@ -0,0 +1,54 @@ +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 object Instance { get; } + + internal Module(CommandService service, object instance, ModuleAttribute moduleAttr, TypeInfo typeInfo) + { + Service = service; + Name = typeInfo.Name; + Instance = instance; + + List commands = new List(); + SearchClass(instance, commands, typeInfo, moduleAttr.Prefix ?? ""); + Commands = commands; + } + + private void SearchClass(object instance, List commands, TypeInfo typeInfo, string groupPrefix) + { + if (groupPrefix != "") + groupPrefix += " "; + foreach (var method in typeInfo.DeclaredMethods) + { + var cmdAttr = method.GetCustomAttribute(); + if (cmdAttr != null) + commands.Add(new Command(this, instance, cmdAttr, method, groupPrefix)); + } + foreach (var type in typeInfo.DeclaredNestedTypes) + { + var groupAttrib = type.GetCustomAttribute(); + if (groupAttrib != null) + { + string nextGroupPrefix; + if (groupAttrib.Prefix != null) + nextGroupPrefix = groupPrefix + groupAttrib.Prefix ?? type.Name; + else + nextGroupPrefix = groupPrefix; + SearchClass(ReflectionUtils.CreateObject(type), commands, type, nextGroupPrefix); + } + } + } + + 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..e4bd9ffd1 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/UserTypeReader.cs @@ -0,0 +1,62 @@ +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) + { + IUser result = null; + + //By Id + ulong id; + if (MentionUtils.TryParseUser(input, out id) || ulong.TryParse(input, out id)) + { + var user = await context.Channel.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 context.Channel.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 context.Channel.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/ReflectionUtils.cs b/src/Discord.Net.Commands/ReflectionUtils.cs new file mode 100644 index 000000000..28672a06f --- /dev/null +++ b/src/Discord.Net.Commands/ReflectionUtils.cs @@ -0,0 +1,24 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace Discord.Commands +{ + internal class ReflectionUtils + { + internal static object CreateObject(TypeInfo typeInfo) + { + var constructor = typeInfo.DeclaredConstructors.Where(x => x.GetParameters().Length == 0).FirstOrDefault(); + if (constructor == null) + throw new InvalidOperationException($"Failed to find a valid constructor for \"{typeInfo.FullName}\""); + try + { + return constructor.Invoke(null); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to create \"{typeInfo.FullName}\"", ex); + } + } + } +} 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..3cda94ba4 --- /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 string Text { get; } + public IReadOnlyList Commands { get; } + + public CommandError? Error { get; } + public string ErrorReason { get; } + + public bool IsSuccess => !Error.HasValue; + + private SearchResult(string text, IReadOnlyList commands, CommandError? error, string errorReason) + { + Text = text; + Commands = commands; + Error = error; + ErrorReason = errorReason; + } + + internal static SearchResult FromSuccess(string text, IReadOnlyList commands) + => new SearchResult(text, commands, 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..beeabab16 --- /dev/null +++ b/src/Discord.Net.Commands/Results/TypeReaderResult.cs @@ -0,0 +1,31 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +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/project.json b/src/Discord.Net.Commands/project.json new file mode 100644 index 000000000..cff4b6ba5 --- /dev/null +++ b/src/Discord.Net.Commands/project.json @@ -0,0 +1,34 @@ +{ + "version": "1.0.0-dev", + "description": "A Discord.Net extension adding command support.", + "authors": [ "RogueException" ], + + "packOptions": { + "tags": [ "discord", "discordapp" ], + "licenseUrl": "http://opensource.org/licenses/MIT", + "projectUrl": "https://github.com/RogueException/Discord.Net", + "repository": { + "type": "git", + "url": "git://github.com/RogueException/Discord.Net" + } + }, + + "buildOptions": { + "allowUnsafe": true, + "warningsAsErrors": false + }, + + "dependencies": { + "Discord.Net": "1.0.0-dev" + }, + + "frameworks": { + "netstandard1.3": { + "imports": [ + "dotnet5.4", + "dnxcore50", + "portable-net45+win8" + ] + } + } +} diff --git a/src/Discord.Net/API/Common/Attachment.cs b/src/Discord.Net/API/Common/Attachment.cs index 1f2c4b8b7..e1561eb84 100644 --- a/src/Discord.Net/API/Common/Attachment.cs +++ b/src/Discord.Net/API/Common/Attachment.cs @@ -15,8 +15,8 @@ namespace Discord.API [JsonProperty("proxy_url")] public string ProxyUrl { get; set; } [JsonProperty("height")] - public int? Height { get; set; } + public Optional Height { get; set; } [JsonProperty("width")] - public int? Width { get; set; } + public Optional Width { get; set; } } } diff --git a/src/Discord.Net/API/Common/Channel.cs b/src/Discord.Net/API/Common/Channel.cs index f50c31a09..b0789b111 100644 --- a/src/Discord.Net/API/Common/Channel.cs +++ b/src/Discord.Net/API/Common/Channel.cs @@ -14,28 +14,28 @@ namespace Discord.API //GuildChannel [JsonProperty("guild_id")] - public ulong? GuildId { get; set; } + public Optional GuildId { get; set; } [JsonProperty("name")] - public string Name { get; set; } + public Optional Name { get; set; } [JsonProperty("type")] - public ChannelType Type { get; set; } + public Optional Type { get; set; } [JsonProperty("position")] - public int Position { get; set; } + public Optional Position { get; set; } [JsonProperty("permission_overwrites")] - public Overwrite[] PermissionOverwrites { get; set; } + public Optional PermissionOverwrites { get; set; } //TextChannel [JsonProperty("topic")] - public string Topic { get; set; } + public Optional Topic { get; set; } //VoiceChannel [JsonProperty("bitrate")] - public int Bitrate { get; set; } + public Optional Bitrate { get; set; } [JsonProperty("user_limit")] - public int UserLimit { get; set; } + public Optional UserLimit { get; set; } //DMChannel [JsonProperty("recipient")] - public User Recipient { get; set; } + public Optional Recipient { get; set; } } } diff --git a/src/Discord.Net/API/Common/Connection.cs b/src/Discord.Net/API/Common/Connection.cs index 8022e0314..fb06ccf21 100644 --- a/src/Discord.Net/API/Common/Connection.cs +++ b/src/Discord.Net/API/Common/Connection.cs @@ -15,6 +15,6 @@ namespace Discord.API public bool Revoked { get; set; } [JsonProperty("integrations")] - public IEnumerable Integrations { get; set; } + public IReadOnlyCollection Integrations { get; set; } } } diff --git a/src/Discord.Net/API/Common/Embed.cs b/src/Discord.Net/API/Common/Embed.cs index 5c732a9d1..394b460dd 100644 --- a/src/Discord.Net/API/Common/Embed.cs +++ b/src/Discord.Net/API/Common/Embed.cs @@ -13,8 +13,8 @@ namespace Discord.API [JsonProperty("url")] public string Url { get; set; } [JsonProperty("thumbnail")] - public EmbedThumbnail Thumbnail { get; set; } + public Optional Thumbnail { get; set; } [JsonProperty("provider")] - public EmbedProvider Provider { get; set; } + public Optional Provider { get; set; } } } diff --git a/src/Discord.Net/API/Common/EmbedThumbnail.cs b/src/Discord.Net/API/Common/EmbedThumbnail.cs index 73fe3472d..8534d427a 100644 --- a/src/Discord.Net/API/Common/EmbedThumbnail.cs +++ b/src/Discord.Net/API/Common/EmbedThumbnail.cs @@ -9,8 +9,8 @@ namespace Discord.API [JsonProperty("proxy_url")] public string ProxyUrl { get; set; } [JsonProperty("height")] - public int? Height { get; set; } + public Optional Height { get; set; } [JsonProperty("width")] - public int? Width { get; set; } + public Optional Width { get; set; } } } diff --git a/src/Discord.Net/API/Common/Game.cs b/src/Discord.Net/API/Common/Game.cs index a5bbbfcdc..ec0fccc84 100644 --- a/src/Discord.Net/API/Common/Game.cs +++ b/src/Discord.Net/API/Common/Game.cs @@ -7,8 +7,8 @@ namespace Discord.API [JsonProperty("name")] public string Name { get; set; } [JsonProperty("url")] - public string StreamUrl { get; set; } + public Optional StreamUrl { get; set; } [JsonProperty("type")] - public StreamType StreamType { get; set; } + public Optional StreamType { get; set; } } } diff --git a/src/Discord.Net/API/Common/Guild.cs b/src/Discord.Net/API/Common/Guild.cs index b4dfcdc11..823405ea7 100644 --- a/src/Discord.Net/API/Common/Guild.cs +++ b/src/Discord.Net/API/Common/Guild.cs @@ -25,7 +25,7 @@ namespace Discord.API [JsonProperty("embed_channel_id")] public ulong? EmbedChannelId { get; set; } [JsonProperty("verification_level")] - public int VerificationLevel { get; set; } + public VerificationLevel VerificationLevel { get; set; } [JsonProperty("voice_states")] public VoiceState[] VoiceStates { get; set; } [JsonProperty("roles")] @@ -34,5 +34,9 @@ namespace Discord.API public Emoji[] Emojis { get; set; } [JsonProperty("features")] public string[] Features { get; set; } + [JsonProperty("mfa_level")] + public MfaLevel MfaLevel { get; set; } + [JsonProperty("default_message_notifications")] + public DefaultMessageNotifications DefaultMessageNotifications { get; set; } } } diff --git a/src/Discord.Net/API/Common/GuildEmbed.cs b/src/Discord.Net/API/Common/GuildEmbed.cs index 9aceaa472..59f933f31 100644 --- a/src/Discord.Net/API/Common/GuildEmbed.cs +++ b/src/Discord.Net/API/Common/GuildEmbed.cs @@ -7,6 +7,6 @@ namespace Discord.API [JsonProperty("enabled")] public bool Enabled { get; set; } [JsonProperty("channel_id")] - public ulong? ChannelId { get; set; } + public ulong ChannelId { get; set; } } } diff --git a/src/Discord.Net/API/Common/GuildMember.cs b/src/Discord.Net/API/Common/GuildMember.cs index 03da0d5bf..99f97990f 100644 --- a/src/Discord.Net/API/Common/GuildMember.cs +++ b/src/Discord.Net/API/Common/GuildMember.cs @@ -8,11 +8,11 @@ namespace Discord.API [JsonProperty("user")] public User User { get; set; } [JsonProperty("nick")] - public string Nick { get; set; } + public Optional Nick { get; set; } [JsonProperty("roles")] public ulong[] Roles { get; set; } [JsonProperty("joined_at")] - public DateTime?JoinedAt { get; set; } + public DateTimeOffset JoinedAt { get; set; } [JsonProperty("deaf")] public bool Deaf { get; set; } [JsonProperty("mute")] diff --git a/src/Discord.Net/API/Common/Integration.cs b/src/Discord.Net/API/Common/Integration.cs index fca50a875..7edd70720 100644 --- a/src/Discord.Net/API/Common/Integration.cs +++ b/src/Discord.Net/API/Common/Integration.cs @@ -26,6 +26,6 @@ namespace Discord.API [JsonProperty("account")] public IntegrationAccount Account { get; set; } [JsonProperty("synced_at")] - public DateTime SyncedAt { get; set; } + public DateTimeOffset SyncedAt { get; set; } } } diff --git a/src/Discord.Net/API/Common/InviteMetadata.cs b/src/Discord.Net/API/Common/InviteMetadata.cs index 55eeebeee..fb46795bb 100644 --- a/src/Discord.Net/API/Common/InviteMetadata.cs +++ b/src/Discord.Net/API/Common/InviteMetadata.cs @@ -16,7 +16,7 @@ namespace Discord.API [JsonProperty("temporary")] public bool Temporary { get; set; } [JsonProperty("created_at")] - public DateTime CreatedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } [JsonProperty("revoked")] public bool Revoked { get; set; } } diff --git a/src/Discord.Net/API/Common/Message.cs b/src/Discord.Net/API/Common/Message.cs index ad8fc2bbe..be5305114 100644 --- a/src/Discord.Net/API/Common/Message.cs +++ b/src/Discord.Net/API/Common/Message.cs @@ -10,24 +10,24 @@ namespace Discord.API [JsonProperty("channel_id")] public ulong ChannelId { get; set; } [JsonProperty("author")] - public User Author { get; set; } + public Optional Author { get; set; } [JsonProperty("content")] - public string Content { get; set; } + public Optional Content { get; set; } [JsonProperty("timestamp")] - public DateTime Timestamp { get; set; } + public Optional Timestamp { get; set; } [JsonProperty("edited_timestamp")] - public DateTime? EditedTimestamp { get; set; } + public Optional EditedTimestamp { get; set; } [JsonProperty("tts")] - public bool IsTextToSpeech { get; set; } + public Optional IsTextToSpeech { get; set; } [JsonProperty("mention_everyone")] - public bool IsMentioningEveryone { get; set; } + public Optional MentionEveryone { get; set; } [JsonProperty("mentions")] - public User[] Mentions { get; set; } + public Optional Mentions { get; set; } [JsonProperty("attachments")] - public Attachment[] Attachments { get; set; } + public Optional Attachments { get; set; } [JsonProperty("embeds")] - public Embed[] Embeds { get; set; } - [JsonProperty("nonce")] - public uint? Nonce { get; set; } + public Optional Embeds { get; set; } + [JsonProperty("pinned")] + public Optional Pinned { get; set; } } } diff --git a/src/Discord.Net/API/Common/Presence.cs b/src/Discord.Net/API/Common/Presence.cs new file mode 100644 index 000000000..16462fa30 --- /dev/null +++ b/src/Discord.Net/API/Common/Presence.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + public class Presence + { + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + [JsonProperty("status")] + public UserStatus Status { get; set; } + [JsonProperty("game")] + public Game Game { get; set; } + + [JsonProperty("roles")] + public Optional Roles { get; set; } + [JsonProperty("nick")] + public Optional Nick { get; set; } + } +} diff --git a/src/Discord.Net/API/Common/ReadState.cs b/src/Discord.Net/API/Common/ReadState.cs index e4177bedf..4a5fa26ef 100644 --- a/src/Discord.Net/API/Common/ReadState.cs +++ b/src/Discord.Net/API/Common/ReadState.cs @@ -9,6 +9,6 @@ namespace Discord.API [JsonProperty("mention_count")] public int MentionCount { get; set; } [JsonProperty("last_message_id")] - public ulong? LastMessageId { get; set; } + public Optional LastMessageId { get; set; } } } diff --git a/src/Discord.Net/API/Common/Relationship.cs b/src/Discord.Net/API/Common/Relationship.cs new file mode 100644 index 000000000..9b52a7750 --- /dev/null +++ b/src/Discord.Net/API/Common/Relationship.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + public class Relationship + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("type")] + public RelationshipType Type { get; set; } + } +} diff --git a/src/Discord.Net/API/Common/RelationshipType.cs b/src/Discord.Net/API/Common/RelationshipType.cs new file mode 100644 index 000000000..53be3a98f --- /dev/null +++ b/src/Discord.Net/API/Common/RelationshipType.cs @@ -0,0 +1,9 @@ +namespace Discord.API +{ + public enum RelationshipType + { + Friend = 1, + Blocked = 2, + Pending = 4 + } +} diff --git a/src/Discord.Net/API/Common/Role.cs b/src/Discord.Net/API/Common/Role.cs index 721b2a50b..90931c182 100644 --- a/src/Discord.Net/API/Common/Role.cs +++ b/src/Discord.Net/API/Common/Role.cs @@ -9,14 +9,14 @@ namespace Discord.API [JsonProperty("name")] public string Name { get; set; } [JsonProperty("color")] - public uint? Color { get; set; } + public uint Color { get; set; } [JsonProperty("hoist")] - public bool? Hoist { get; set; } + public bool Hoist { get; set; } [JsonProperty("position")] - public int? Position { get; set; } + public int Position { get; set; } [JsonProperty("permissions"), Int53] - public ulong? Permissions { get; set; } + public ulong Permissions { get; set; } [JsonProperty("managed")] - public bool? Managed { get; set; } + public bool Managed { get; set; } } } diff --git a/src/Discord.Net/API/Common/User.cs b/src/Discord.Net/API/Common/User.cs index c8e566711..9c4ff6911 100644 --- a/src/Discord.Net/API/Common/User.cs +++ b/src/Discord.Net/API/Common/User.cs @@ -9,14 +9,18 @@ namespace Discord.API [JsonProperty("username")] public string Username { get; set; } [JsonProperty("discriminator")] - public ushort Discriminator { get; set; } + public string Discriminator { get; set; } + [JsonProperty("bot")] + public bool Bot { get; set; } [JsonProperty("avatar")] public string Avatar { get; set; } + + //CurrentUser [JsonProperty("verified")] - public bool IsVerified { get; set; } + public bool Verified { get; set; } [JsonProperty("email")] public string Email { get; set; } - [JsonProperty("bot")] - public bool Bot { get; set; } + [JsonProperty("mfa_enabled")] + public bool MfaEnabled { get; set; } } } diff --git a/src/Discord.Net/API/Common/VoiceState.cs b/src/Discord.Net/API/Common/VoiceState.cs index f584468af..49a7409d0 100644 --- a/src/Discord.Net/API/Common/VoiceState.cs +++ b/src/Discord.Net/API/Common/VoiceState.cs @@ -7,7 +7,7 @@ namespace Discord.API [JsonProperty("guild_id")] public ulong? GuildId { get; set; } [JsonProperty("channel_id")] - public ulong ChannelId { get; set; } + public ulong? ChannelId { get; set; } [JsonProperty("user_id")] public ulong UserId { get; set; } [JsonProperty("session_id")] diff --git a/src/Discord.Net/API/DiscordAPIClient.cs b/src/Discord.Net/API/DiscordAPIClient.cs index dee9fddf0..8bcd4b079 100644 --- a/src/Discord.Net/API/DiscordAPIClient.cs +++ b/src/Discord.Net/API/DiscordAPIClient.cs @@ -6,7 +6,6 @@ using Discord.Net.Queue; using Discord.Net.Rest; using Discord.Net.WebSockets; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -24,9 +23,17 @@ namespace Discord.API { public class DiscordApiClient : IDisposable { - public event Func SentRequest; - public event Func SentGatewayMessage; - public event Func ReceivedGatewayEvent; + private object _eventLock = new object(); + + public event Func SentRequest { add { _sentRequestEvent.Add(value); } remove { _sentRequestEvent.Remove(value); } } + private readonly AsyncEvent> _sentRequestEvent = new AsyncEvent>(); + public event Func SentGatewayMessage { add { _sentGatewayMessageEvent.Add(value); } remove { _sentGatewayMessageEvent.Remove(value); } } + private readonly AsyncEvent> _sentGatewayMessageEvent = new AsyncEvent>(); + + public event Func ReceivedGatewayEvent { add { _receivedGatewayEvent.Add(value); } remove { _receivedGatewayEvent.Remove(value); } } + private readonly AsyncEvent> _receivedGatewayEvent = new AsyncEvent>(); + public event Func Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); private readonly RequestQueue _requestQueue; private readonly JsonSerializer _serializer; @@ -35,6 +42,7 @@ namespace Discord.API private readonly SemaphoreSlim _connectionLock; private CancellationTokenSource _loginCancelToken, _connectCancelToken; private string _authToken; + private string _gatewayUrl; private bool _isDisposed; public LoginState LoginState { get; private set; } @@ -53,7 +61,7 @@ namespace Discord.API if (webSocketProvider != null) { _gatewayClient = webSocketProvider(); - _gatewayClient.SetHeader("user-agent", DiscordConfig.UserAgent); + //_gatewayClient.SetHeader("user-agent", DiscordConfig.UserAgent); (Causes issues in .Net 4.6+) _gatewayClient.BinaryMessage += async (data, index, count) => { using (var compressed = new MemoryStream(data, index + 2, count - 2)) @@ -65,20 +73,25 @@ namespace Discord.API using (var reader = new StreamReader(decompressed)) { var msg = JsonConvert.DeserializeObject(reader.ReadToEnd()); - await ReceivedGatewayEvent.Raise((GatewayOpCodes)msg.Operation, msg.Type, msg.Payload as JToken).ConfigureAwait(false); + await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); } } }; _gatewayClient.TextMessage += async text => { var msg = JsonConvert.DeserializeObject(text); - await ReceivedGatewayEvent.Raise((GatewayOpCodes)msg.Operation, msg.Type, msg.Payload as JToken).ConfigureAwait(false); + await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); + }; + _gatewayClient.Closed += async ex => + { + await DisconnectAsync().ConfigureAwait(false); + await _disconnectedEvent.InvokeAsync(ex).ConfigureAwait(false); }; } _serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver() }; } - void Dispose(bool disposing) + private void Dispose(bool disposing) { if (!_isDisposed) { @@ -86,34 +99,27 @@ namespace Discord.API { _loginCancelToken?.Dispose(); _connectCancelToken?.Dispose(); + (_restClient as IDisposable)?.Dispose(); + (_gatewayClient as IDisposable)?.Dispose(); } _isDisposed = true; } } public void Dispose() => Dispose(true); - - public async Task Login(LoginParams args) - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await LoginInternal(TokenType.User, null, args, true).ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - public async Task Login(TokenType tokenType, string token) + + public async Task LoginAsync(TokenType tokenType, string token, RequestOptions options = null) { await _connectionLock.WaitAsync().ConfigureAwait(false); try { - await LoginInternal(tokenType, token, null, false).ConfigureAwait(false); + await LoginInternalAsync(tokenType, token, options).ConfigureAwait(false); } finally { _connectionLock.Release(); } } - private async Task LoginInternal(TokenType tokenType, string token, LoginParams args, bool doLogin) + private async Task LoginInternalAsync(TokenType tokenType, string token, RequestOptions options = null) { if (LoginState != LoginState.LoggedOut) - await LogoutInternal().ConfigureAwait(false); + await LogoutInternalAsync().ConfigureAwait(false); LoginState = LoginState.LoggingIn; try @@ -123,15 +129,9 @@ namespace Discord.API AuthTokenType = TokenType.User; _authToken = null; _restClient.SetHeader("authorization", null); - await _requestQueue.SetCancelToken(_loginCancelToken.Token).ConfigureAwait(false); + await _requestQueue.SetCancelTokenAsync(_loginCancelToken.Token).ConfigureAwait(false); _restClient.SetCancelToken(_loginCancelToken.Token); - if (doLogin) - { - var response = await Send("POST", "auth/login", args, GlobalBucket.Login).ConfigureAwait(false); - token = response.Token; - } - AuthTokenType = tokenType; _authToken = token; switch (tokenType) @@ -153,48 +153,48 @@ namespace Discord.API } catch (Exception) { - await LogoutInternal().ConfigureAwait(false); + await LogoutInternalAsync().ConfigureAwait(false); throw; } } - public async Task Logout() + public async Task LogoutAsync() { await _connectionLock.WaitAsync().ConfigureAwait(false); try { - await LogoutInternal().ConfigureAwait(false); + await LogoutInternalAsync().ConfigureAwait(false); } finally { _connectionLock.Release(); } } - private async Task LogoutInternal() + private async Task LogoutInternalAsync() { - //TODO: An exception here will lock the client into the unusable LoggingOut state. How should we handle? (Add same solution to both DiscordClients too) + //An exception here will lock the client into the unusable LoggingOut state, but that's probably fine since our client is in an undefined state too. if (LoginState == LoginState.LoggedOut) return; LoginState = LoginState.LoggingOut; try { _loginCancelToken?.Cancel(false); } catch { } - await DisconnectInternal().ConfigureAwait(false); - await _requestQueue.Clear().ConfigureAwait(false); + await DisconnectInternalAsync().ConfigureAwait(false); + await _requestQueue.ClearAsync().ConfigureAwait(false); - await _requestQueue.SetCancelToken(CancellationToken.None).ConfigureAwait(false); + await _requestQueue.SetCancelTokenAsync(CancellationToken.None).ConfigureAwait(false); _restClient.SetCancelToken(CancellationToken.None); LoginState = LoginState.LoggedOut; } - public async Task Connect() + public async Task ConnectAsync() { await _connectionLock.WaitAsync().ConfigureAwait(false); try { - await ConnectInternal().ConfigureAwait(false); + await ConnectInternalAsync().ConfigureAwait(false); } finally { _connectionLock.Release(); } } - private async Task ConnectInternal() + private async Task ConnectInternalAsync() { if (LoginState != LoginState.LoggedIn) throw new InvalidOperationException("You must log in before connecting."); @@ -208,31 +208,33 @@ namespace Discord.API if (_gatewayClient != null) _gatewayClient.SetCancelToken(_connectCancelToken.Token); - var gatewayResponse = await GetGateway().ConfigureAwait(false); - var url = $"{gatewayResponse.Url}?v={DiscordConfig.GatewayAPIVersion}&encoding={DiscordConfig.GatewayEncoding}"; - await _gatewayClient.Connect(url).ConfigureAwait(false); - - await SendIdentify().ConfigureAwait(false); + if (_gatewayUrl == null) + { + var gatewayResponse = await GetGatewayAsync().ConfigureAwait(false); + _gatewayUrl = $"{gatewayResponse.Url}?v={DiscordConfig.GatewayAPIVersion}&encoding={DiscordConfig.GatewayEncoding}"; + } + await _gatewayClient.ConnectAsync(_gatewayUrl).ConfigureAwait(false); ConnectionState = ConnectionState.Connected; } catch (Exception) { - await DisconnectInternal().ConfigureAwait(false); + _gatewayUrl = null; //Uncache in case the gateway url changed + await DisconnectInternalAsync().ConfigureAwait(false); throw; } } - public async Task Disconnect() + public async Task DisconnectAsync() { await _connectionLock.WaitAsync().ConfigureAwait(false); try { - await DisconnectInternal().ConfigureAwait(false); + await DisconnectInternalAsync().ConfigureAwait(false); } finally { _connectionLock.Release(); } } - private async Task DisconnectInternal() + private async Task DisconnectInternalAsync() { if (_gatewayClient == null) throw new NotSupportedException("This client is not configured with websocket support."); @@ -243,110 +245,128 @@ namespace Discord.API try { _connectCancelToken?.Cancel(false); } catch { } - await _gatewayClient.Disconnect().ConfigureAwait(false); + await _gatewayClient.DisconnectAsync().ConfigureAwait(false); ConnectionState = ConnectionState.Disconnected; } //Core - public Task Send(string method, string endpoint, GlobalBucket bucket = GlobalBucket.General) - => SendInternal(method, endpoint, null, true, bucket); - public Task Send(string method, string endpoint, object payload, GlobalBucket bucket = GlobalBucket.General) - => SendInternal(method, endpoint, payload, true, bucket); - public Task Send(string method, string endpoint, Stream file, IReadOnlyDictionary multipartArgs, GlobalBucket bucket = GlobalBucket.General) - => SendInternal(method, endpoint, multipartArgs, true, bucket); - public async Task Send(string method, string endpoint, GlobalBucket bucket = GlobalBucket.General) + public Task SendAsync(string method, string endpoint, + GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) + => SendInternalAsync(method, endpoint, null, true, bucket, options); + public Task SendAsync(string method, string endpoint, object payload, + GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) + => SendInternalAsync(method, endpoint, payload, true, bucket, options); + public Task SendAsync(string method, string endpoint, Stream file, IReadOnlyDictionary multipartArgs, + GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) + => SendInternalAsync(method, endpoint, multipartArgs, true, bucket, options); + public async Task SendAsync(string method, string endpoint, + GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) where TResponse : class - => DeserializeJson(await SendInternal(method, endpoint, null, false, bucket).ConfigureAwait(false)); - public async Task Send(string method, string endpoint, object payload, GlobalBucket bucket = GlobalBucket.General) + => DeserializeJson(await SendInternalAsync(method, endpoint, null, false, bucket, options).ConfigureAwait(false)); + public async Task SendAsync(string method, string endpoint, object payload, GlobalBucket bucket = + GlobalBucket.GeneralRest, RequestOptions options = null) where TResponse : class - => DeserializeJson(await SendInternal(method, endpoint, payload, false, bucket).ConfigureAwait(false)); - public async Task Send(string method, string endpoint, Stream file, IReadOnlyDictionary multipartArgs, GlobalBucket bucket = GlobalBucket.General) + => DeserializeJson(await SendInternalAsync(method, endpoint, payload, false, bucket, options).ConfigureAwait(false)); + public async Task SendAsync(string method, string endpoint, Stream file, IReadOnlyDictionary multipartArgs, + GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) where TResponse : class - => DeserializeJson(await SendInternal(method, endpoint, multipartArgs, false, bucket).ConfigureAwait(false)); - - public Task Send(string method, string endpoint, GuildBucket bucket, ulong guildId) - => SendInternal(method, endpoint, null, true, bucket, guildId); - public Task Send(string method, string endpoint, object payload, GuildBucket bucket, ulong guildId) - => SendInternal(method, endpoint, payload, true, bucket, guildId); - public Task Send(string method, string endpoint, Stream file, IReadOnlyDictionary multipartArgs, GuildBucket bucket, ulong guildId) - => SendInternal(method, endpoint, multipartArgs, true, bucket, guildId); - public async Task Send(string method, string endpoint, GuildBucket bucket, ulong guildId) + => DeserializeJson(await SendInternalAsync(method, endpoint, multipartArgs, false, bucket, options).ConfigureAwait(false)); + + public Task SendAsync(string method, string endpoint, + GuildBucket bucket, ulong guildId, RequestOptions options = null) + => SendInternalAsync(method, endpoint, null, true, bucket, guildId, options); + public Task SendAsync(string method, string endpoint, object payload, + GuildBucket bucket, ulong guildId, RequestOptions options = null) + => SendInternalAsync(method, endpoint, payload, true, bucket, guildId, options); + public Task SendAsync(string method, string endpoint, Stream file, IReadOnlyDictionary multipartArgs, + GuildBucket bucket, ulong guildId, RequestOptions options = null) + => SendInternalAsync(method, endpoint, multipartArgs, true, bucket, guildId, options); + public async Task SendAsync(string method, string endpoint, + GuildBucket bucket, ulong guildId, RequestOptions options = null) where TResponse : class - => DeserializeJson(await SendInternal(method, endpoint, null, false, bucket, guildId).ConfigureAwait(false)); - public async Task Send(string method, string endpoint, object payload, GuildBucket bucket, ulong guildId) + => DeserializeJson(await SendInternalAsync(method, endpoint, null, false, bucket, guildId, options).ConfigureAwait(false)); + public async Task SendAsync(string method, string endpoint, object payload, + GuildBucket bucket, ulong guildId, RequestOptions options = null) where TResponse : class - => DeserializeJson(await SendInternal(method, endpoint, payload, false, bucket, guildId).ConfigureAwait(false)); - public async Task Send(string method, string endpoint, Stream file, IReadOnlyDictionary multipartArgs, GuildBucket bucket, ulong guildId) + => DeserializeJson(await SendInternalAsync(method, endpoint, payload, false, bucket, guildId, options).ConfigureAwait(false)); + public async Task SendAsync(string method, string endpoint, Stream file, IReadOnlyDictionary multipartArgs, + GuildBucket bucket, ulong guildId, RequestOptions options = null) where TResponse : class - => DeserializeJson(await SendInternal(method, endpoint, multipartArgs, false, bucket, guildId).ConfigureAwait(false)); + => DeserializeJson(await SendInternalAsync(method, endpoint, multipartArgs, false, bucket, guildId, options).ConfigureAwait(false)); - private Task SendInternal(string method, string endpoint, object payload, bool headerOnly, GlobalBucket bucket) - => SendInternal(method, endpoint, payload, headerOnly, BucketGroup.Global, (int)bucket, 0); - private Task SendInternal(string method, string endpoint, object payload, bool headerOnly, GuildBucket bucket, ulong guildId) - => SendInternal(method, endpoint, payload, headerOnly, BucketGroup.Guild, (int)bucket, guildId); - private Task SendInternal(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, GlobalBucket bucket) - => SendInternal(method, endpoint, multipartArgs, headerOnly, BucketGroup.Global, (int)bucket, 0); - private Task SendInternal(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, GuildBucket bucket, ulong guildId) - => SendInternal(method, endpoint, multipartArgs, headerOnly, BucketGroup.Guild, (int)bucket, guildId); - - private async Task SendInternal(string method, string endpoint, object payload, bool headerOnly, BucketGroup group, int bucketId, ulong guildId) + private Task SendInternalAsync(string method, string endpoint, object payload, bool headerOnly, + GlobalBucket bucket, RequestOptions options) + => SendInternalAsync(method, endpoint, payload, headerOnly, BucketGroup.Global, (int)bucket, 0, options); + private Task SendInternalAsync(string method, string endpoint, object payload, bool headerOnly, + GuildBucket bucket, ulong guildId, RequestOptions options) + => SendInternalAsync(method, endpoint, payload, headerOnly, BucketGroup.Guild, (int)bucket, guildId, options); + private Task SendInternalAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, + GlobalBucket bucket, RequestOptions options) + => SendInternalAsync(method, endpoint, multipartArgs, headerOnly, BucketGroup.Global, (int)bucket, 0, options); + private Task SendInternalAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, + GuildBucket bucket, ulong guildId, RequestOptions options) + => SendInternalAsync(method, endpoint, multipartArgs, headerOnly, BucketGroup.Guild, (int)bucket, guildId, options); + + private async Task SendInternalAsync(string method, string endpoint, object payload, bool headerOnly, + BucketGroup group, int bucketId, ulong guildId, RequestOptions options = null) { var stopwatch = Stopwatch.StartNew(); string json = null; if (payload != null) json = SerializeJson(payload); - var responseStream = await _requestQueue.Send(new RestRequest(_restClient, method, endpoint, json, headerOnly), group, bucketId, guildId).ConfigureAwait(false); + var responseStream = await _requestQueue.SendAsync(new RestRequest(_restClient, method, endpoint, json, headerOnly, options), group, bucketId, guildId).ConfigureAwait(false); stopwatch.Stop(); double milliseconds = ToMilliseconds(stopwatch); - await SentRequest.Raise(method, endpoint, milliseconds).ConfigureAwait(false); + await _sentRequestEvent.InvokeAsync(method, endpoint, milliseconds).ConfigureAwait(false); return responseStream; } - private async Task SendInternal(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, BucketGroup group, int bucketId, ulong guildId) + private async Task SendInternalAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, + BucketGroup group, int bucketId, ulong guildId, RequestOptions options = null) { var stopwatch = Stopwatch.StartNew(); - var responseStream = await _requestQueue.Send(new RestRequest(_restClient, method, endpoint, multipartArgs, headerOnly), group, bucketId, guildId).ConfigureAwait(false); + var responseStream = await _requestQueue.SendAsync(new RestRequest(_restClient, method, endpoint, multipartArgs, headerOnly, options), group, bucketId, guildId).ConfigureAwait(false); int bytes = headerOnly ? 0 : (int)responseStream.Length; stopwatch.Stop(); double milliseconds = ToMilliseconds(stopwatch); - await SentRequest.Raise(method, endpoint, milliseconds).ConfigureAwait(false); + await _sentRequestEvent.InvokeAsync(method, endpoint, milliseconds).ConfigureAwait(false); return responseStream; } - public Task SendGateway(GatewayOpCodes opCode, object payload, GlobalBucket bucket = GlobalBucket.Gateway) - => SendGateway((int)opCode, payload, BucketGroup.Global, (int)bucket, 0); - public Task SendGateway(VoiceOpCodes opCode, object payload, GlobalBucket bucket = GlobalBucket.Gateway) - => SendGateway((int)opCode, payload, BucketGroup.Global, (int)bucket, 0); - public Task SendGateway(GatewayOpCodes opCode, object payload, GuildBucket bucket, ulong guildId) - => SendGateway((int)opCode, payload, BucketGroup.Guild, (int)bucket, guildId); - public Task SendGateway(VoiceOpCodes opCode, object payload, GuildBucket bucket, ulong guildId) - => SendGateway((int)opCode, payload, BucketGroup.Guild, (int)bucket, guildId); - private async Task SendGateway(int opCode, object payload, BucketGroup group, int bucketId, ulong guildId) + public Task SendGatewayAsync(GatewayOpCode opCode, object payload, + GlobalBucket bucket = GlobalBucket.GeneralGateway, RequestOptions options = null) + => SendGatewayAsync(opCode, payload, BucketGroup.Global, (int)bucket, 0, options); + public Task SendGatewayAsync(GatewayOpCode opCode, object payload, + GuildBucket bucket, ulong guildId, RequestOptions options = null) + => SendGatewayAsync(opCode, payload, BucketGroup.Guild, (int)bucket, guildId, options); + private async Task SendGatewayAsync(GatewayOpCode opCode, object payload, + BucketGroup group, int bucketId, ulong guildId, RequestOptions options) { //TODO: Add ETF byte[] bytes = null; - payload = new WebSocketMessage { Operation = opCode, Payload = payload }; + payload = new WebSocketMessage { Operation = (int)opCode, Payload = payload }; if (payload != null) bytes = Encoding.UTF8.GetBytes(SerializeJson(payload)); - await _requestQueue.Send(new WebSocketRequest(_gatewayClient, bytes, true), group, bucketId, guildId).ConfigureAwait(false); + await _requestQueue.SendAsync(new WebSocketRequest(_gatewayClient, bytes, true, options), group, bucketId, guildId).ConfigureAwait(false); + await _sentGatewayMessageEvent.InvokeAsync(opCode).ConfigureAwait(false); } //Auth - public async Task ValidateToken() + public async Task ValidateTokenAsync(RequestOptions options = null) { - await Send("GET", "auth/login").ConfigureAwait(false); + await SendAsync("GET", "auth/login", options: options).ConfigureAwait(false); } //Gateway - public async Task GetGateway() + public async Task GetGatewayAsync(RequestOptions options = null) { - return await Send("GET", "gateway").ConfigureAwait(false); + return await SendAsync("GET", "gateway", options: options).ConfigureAwait(false); } - public async Task SendIdentify(int largeThreshold = 100, bool useCompression = true) + public async Task SendIdentifyAsync(int largeThreshold = 100, bool useCompression = true, RequestOptions options = null) { var props = new Dictionary { @@ -359,74 +379,115 @@ namespace Discord.API LargeThreshold = largeThreshold, UseCompression = useCompression }; - await SendGateway(GatewayOpCodes.Identify, msg).ConfigureAwait(false); + await SendGatewayAsync(GatewayOpCode.Identify, msg, options: options).ConfigureAwait(false); + } + public async Task SendResumeAsync(string sessionId, int lastSeq, RequestOptions options = null) + { + var msg = new ResumeParams() + { + SessionId = sessionId, + Sequence = lastSeq + }; + await SendGatewayAsync(GatewayOpCode.Resume, msg, options: options).ConfigureAwait(false); + } + public async Task SendHeartbeatAsync(int lastSeq, RequestOptions options = null) + { + await SendGatewayAsync(GatewayOpCode.Heartbeat, lastSeq, options: options).ConfigureAwait(false); + } + public async Task SendStatusUpdateAsync(long? idleSince, Game game, RequestOptions options = null) + { + var args = new StatusUpdateParams + { + IdleSince = idleSince, + Game = game + }; + await SendGatewayAsync(GatewayOpCode.StatusUpdate, args, options: options).ConfigureAwait(false); + } + public async Task SendRequestMembersAsync(IEnumerable guildIds, RequestOptions options = null) + { + await SendGatewayAsync(GatewayOpCode.RequestGuildMembers, new RequestMembersParams { GuildIds = guildIds, Query = "", Limit = 0 }, options: options).ConfigureAwait(false); + } + public async Task SendVoiceStateUpdateAsync(ulong guildId, ulong? channelId, bool selfDeaf, bool selfMute, RequestOptions options = null) + { + var payload = new VoiceStateUpdateParams + { + GuildId = guildId, + ChannelId = channelId, + SelfDeaf = selfDeaf, + SelfMute = selfMute + }; + await SendGatewayAsync(GatewayOpCode.VoiceStateUpdate, payload, options: options).ConfigureAwait(false); + } + public async Task SendGuildSyncAsync(IEnumerable guildIds, RequestOptions options = null) + { + await SendGatewayAsync(GatewayOpCode.GuildSync, guildIds, options: options).ConfigureAwait(false); } //Channels - public async Task GetChannel(ulong channelId) + public async Task GetChannelAsync(ulong channelId, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); try { - return await Send("GET", $"channels/{channelId}").ConfigureAwait(false); + return await SendAsync("GET", $"channels/{channelId}", options: options).ConfigureAwait(false); } catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task GetChannel(ulong guildId, ulong channelId) + public async Task GetChannelAsync(ulong guildId, ulong channelId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(channelId, 0, nameof(channelId)); try { - var model = await Send("GET", $"channels/{channelId}").ConfigureAwait(false); - if (model.GuildId != guildId) + var model = await SendAsync("GET", $"channels/{channelId}", options: options).ConfigureAwait(false); + if (!model.GuildId.IsSpecified || model.GuildId.Value != guildId) return null; return model; } catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task> GetGuildChannels(ulong guildId) + public async Task> GetGuildChannelsAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send>("GET", $"guilds/{guildId}/channels").ConfigureAwait(false); + return await SendAsync>("GET", $"guilds/{guildId}/channels", options: options).ConfigureAwait(false); } - public async Task CreateGuildChannel(ulong guildId, CreateGuildChannelParams args) + public async Task CreateGuildChannelAsync(ulong guildId, CreateGuildChannelParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotNull(args, nameof(args)); Preconditions.GreaterThan(args.Bitrate, 0, nameof(args.Bitrate)); Preconditions.NotNullOrWhitespace(args.Name, nameof(args.Name)); - return await Send("POST", $"guilds/{guildId}/channels", args).ConfigureAwait(false); + return await SendAsync("POST", $"guilds/{guildId}/channels", args, options: options).ConfigureAwait(false); } - public async Task DeleteChannel(ulong channelId) + public async Task DeleteChannelAsync(ulong channelId, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); - return await Send("DELETE", $"channels/{channelId}").ConfigureAwait(false); + return await SendAsync("DELETE", $"channels/{channelId}", options: options).ConfigureAwait(false); } - public async Task ModifyGuildChannel(ulong channelId, ModifyGuildChannelParams args) + public async Task ModifyGuildChannelAsync(ulong channelId, ModifyGuildChannelParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotNull(args, nameof(args)); Preconditions.AtLeast(args.Position, 0, nameof(args.Position)); Preconditions.NotNullOrEmpty(args.Name, nameof(args.Name)); - return await Send("PATCH", $"channels/{channelId}", args).ConfigureAwait(false); + return await SendAsync("PATCH", $"channels/{channelId}", args, options: options).ConfigureAwait(false); } - public async Task ModifyGuildChannel(ulong channelId, ModifyTextChannelParams args) + public async Task ModifyGuildChannelAsync(ulong channelId, ModifyTextChannelParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotNull(args, nameof(args)); Preconditions.AtLeast(args.Position, 0, nameof(args.Position)); Preconditions.NotNullOrEmpty(args.Name, nameof(args.Name)); - return await Send("PATCH", $"channels/{channelId}", args).ConfigureAwait(false); + return await SendAsync("PATCH", $"channels/{channelId}", args, options: options).ConfigureAwait(false); } - public async Task ModifyGuildChannel(ulong channelId, ModifyVoiceChannelParams args) + public async Task ModifyGuildChannelAsync(ulong channelId, ModifyVoiceChannelParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotNull(args, nameof(args)); @@ -435,9 +496,9 @@ namespace Discord.API Preconditions.AtLeast(args.Position, 0, nameof(args.Position)); Preconditions.NotNullOrEmpty(args.Name, nameof(args.Name)); - return await Send("PATCH", $"channels/{channelId}", args).ConfigureAwait(false); + return await SendAsync("PATCH", $"channels/{channelId}", args, options: options).ConfigureAwait(false); } - public async Task ModifyGuildChannels(ulong guildId, IEnumerable args) + public async Task ModifyGuildChannelsAsync(ulong guildId, IEnumerable args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotNull(args, nameof(args)); @@ -448,153 +509,174 @@ namespace Discord.API case 0: return; case 1: - await ModifyGuildChannel(channels[0].Id, new ModifyGuildChannelParams { Position = channels[0].Position }).ConfigureAwait(false); + await ModifyGuildChannelAsync(channels[0].Id, new ModifyGuildChannelParams { Position = channels[0].Position }).ConfigureAwait(false); break; default: - await Send("PATCH", $"guilds/{guildId}/channels", channels).ConfigureAwait(false); + await SendAsync("PATCH", $"guilds/{guildId}/channels", channels, options: options).ConfigureAwait(false); break; } } //Channel Permissions - public async Task ModifyChannelPermissions(ulong channelId, ulong targetId, ModifyChannelPermissionsParams args) + public async Task ModifyChannelPermissionsAsync(ulong channelId, ulong targetId, ModifyChannelPermissionsParams args, RequestOptions options = null) { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(targetId, 0, nameof(targetId)); Preconditions.NotNull(args, nameof(args)); - await Send("PUT", $"channels/{channelId}/permissions/{targetId}", args).ConfigureAwait(false); + await SendAsync("PUT", $"channels/{channelId}/permissions/{targetId}", args, options: options).ConfigureAwait(false); + } + public async Task DeleteChannelPermissionAsync(ulong channelId, ulong targetId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(targetId, 0, nameof(targetId)); + + await SendAsync("DELETE", $"channels/{channelId}/permissions/{targetId}", options: options).ConfigureAwait(false); + } + + //Channel Pins + public async Task AddPinAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.GreaterThan(channelId, 0, nameof(channelId)); + Preconditions.GreaterThan(messageId, 0, nameof(messageId)); + + await SendAsync("PUT", $"channels/{channelId}/pins/{messageId}", options: options).ConfigureAwait(false); + } - public async Task DeleteChannelPermission(ulong channelId, ulong targetId) + public async Task RemovePinAsync(ulong channelId, ulong messageId, RequestOptions options = null) { - await Send("DELETE", $"channels/{channelId}/permissions/{targetId}").ConfigureAwait(false); + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + + await SendAsync("DELETE", $"channels/{channelId}/pins/{messageId}", options: options).ConfigureAwait(false); } //Guilds - public async Task GetGuild(ulong guildId) + public async Task GetGuildAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); try { - return await Send("GET", $"guilds/{guildId}").ConfigureAwait(false); + return await SendAsync("GET", $"guilds/{guildId}", options: options).ConfigureAwait(false); } catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task CreateGuild(CreateGuildParams args) + public async Task CreateGuildAsync(CreateGuildParams args, RequestOptions options = null) { Preconditions.NotNull(args, nameof(args)); Preconditions.NotNullOrWhitespace(args.Name, nameof(args.Name)); Preconditions.NotNullOrWhitespace(args.Region, nameof(args.Region)); - return await Send("POST", "guilds", args).ConfigureAwait(false); + return await SendAsync("POST", "guilds", args, options: options).ConfigureAwait(false); } - public async Task DeleteGuild(ulong guildId) + public async Task DeleteGuildAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send("DELETE", $"guilds/{guildId}").ConfigureAwait(false); + return await SendAsync("DELETE", $"guilds/{guildId}", options: options).ConfigureAwait(false); } - public async Task LeaveGuild(ulong guildId) + public async Task LeaveGuildAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send("DELETE", $"users/@me/guilds/{guildId}").ConfigureAwait(false); + return await SendAsync("DELETE", $"users/@me/guilds/{guildId}", options: options).ConfigureAwait(false); } - public async Task ModifyGuild(ulong guildId, ModifyGuildParams args) + public async Task ModifyGuildAsync(ulong guildId, ModifyGuildParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotNull(args, nameof(args)); Preconditions.NotEqual(args.AFKChannelId, 0, nameof(args.AFKChannelId)); Preconditions.AtLeast(args.AFKTimeout, 0, nameof(args.AFKTimeout)); Preconditions.NotNullOrEmpty(args.Name, nameof(args.Name)); - Preconditions.NotNull(args.Owner, nameof(args.Owner)); + Preconditions.GreaterThan(args.OwnerId, 0, nameof(args.OwnerId)); Preconditions.NotNull(args.Region, nameof(args.Region)); - Preconditions.AtLeast(args.VerificationLevel, 0, nameof(args.VerificationLevel)); - return await Send("PATCH", $"guilds/{guildId}", args).ConfigureAwait(false); + return await SendAsync("PATCH", $"guilds/{guildId}", args, options: options).ConfigureAwait(false); } - public async Task BeginGuildPrune(ulong guildId, GuildPruneParams args) + public async Task BeginGuildPruneAsync(ulong guildId, GuildPruneParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotNull(args, nameof(args)); Preconditions.AtLeast(args.Days, 0, nameof(args.Days)); - return await Send("POST", $"guilds/{guildId}/prune", args).ConfigureAwait(false); + return await SendAsync("POST", $"guilds/{guildId}/prune", args, options: options).ConfigureAwait(false); } - public async Task GetGuildPruneCount(ulong guildId, GuildPruneParams args) + public async Task GetGuildPruneCountAsync(ulong guildId, GuildPruneParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotNull(args, nameof(args)); Preconditions.AtLeast(args.Days, 0, nameof(args.Days)); - return await Send("GET", $"guilds/{guildId}/prune", args).ConfigureAwait(false); + return await SendAsync("GET", $"guilds/{guildId}/prune", args, options: options).ConfigureAwait(false); } //Guild Bans - public async Task> GetGuildBans(ulong guildId) + public async Task> GetGuildBansAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send>("GET", $"guilds/{guildId}/bans").ConfigureAwait(false); + return await SendAsync>("GET", $"guilds/{guildId}/bans", options: options).ConfigureAwait(false); } - public async Task CreateGuildBan(ulong guildId, ulong userId, CreateGuildBanParams args) + public async Task CreateGuildBanAsync(ulong guildId, ulong userId, CreateGuildBanParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(userId, 0, nameof(userId)); Preconditions.NotNull(args, nameof(args)); Preconditions.AtLeast(args.PruneDays, 0, nameof(args.PruneDays)); - await Send("PUT", $"guilds/{guildId}/bans/{userId}", args).ConfigureAwait(false); + await SendAsync("PUT", $"guilds/{guildId}/bans/{userId}", args, options: options).ConfigureAwait(false); } - public async Task RemoveGuildBan(ulong guildId, ulong userId) + public async Task RemoveGuildBanAsync(ulong guildId, ulong userId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(userId, 0, nameof(userId)); - await Send("DELETE", $"guilds/{guildId}/bans/{userId}").ConfigureAwait(false); + await SendAsync("DELETE", $"guilds/{guildId}/bans/{userId}", options: options).ConfigureAwait(false); } //Guild Embeds - public async Task GetGuildEmbed(ulong guildId) + public async Task GetGuildEmbedAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); try { - return await Send("GET", $"guilds/{guildId}/embed").ConfigureAwait(false); + return await SendAsync("GET", $"guilds/{guildId}/embed", options: options).ConfigureAwait(false); } catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task ModifyGuildEmbed(ulong guildId, ModifyGuildEmbedParams args) + public async Task ModifyGuildEmbedAsync(ulong guildId, ModifyGuildEmbedParams args, RequestOptions options = null) { Preconditions.NotNull(args, nameof(args)); Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send("PATCH", $"guilds/{guildId}/embed", args).ConfigureAwait(false); + return await SendAsync("PATCH", $"guilds/{guildId}/embed", args, options: options).ConfigureAwait(false); } //Guild Integrations - public async Task> GetGuildIntegrations(ulong guildId) + public async Task> GetGuildIntegrationsAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send>("GET", $"guilds/{guildId}/integrations").ConfigureAwait(false); + return await SendAsync>("GET", $"guilds/{guildId}/integrations", options: options).ConfigureAwait(false); } - public async Task CreateGuildIntegration(ulong guildId, CreateGuildIntegrationParams args) + public async Task CreateGuildIntegrationAsync(ulong guildId, CreateGuildIntegrationParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotNull(args, nameof(args)); Preconditions.NotEqual(args.Id, 0, nameof(args.Id)); - return await Send("POST", $"guilds/{guildId}/integrations").ConfigureAwait(false); + return await SendAsync("POST", $"guilds/{guildId}/integrations", options: options).ConfigureAwait(false); } - public async Task DeleteGuildIntegration(ulong guildId, ulong integrationId) + public async Task DeleteGuildIntegrationAsync(ulong guildId, ulong integrationId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(integrationId, 0, nameof(integrationId)); - return await Send("DELETE", $"guilds/{guildId}/integrations/{integrationId}").ConfigureAwait(false); + return await SendAsync("DELETE", $"guilds/{guildId}/integrations/{integrationId}", options: options).ConfigureAwait(false); } - public async Task ModifyGuildIntegration(ulong guildId, ulong integrationId, ModifyGuildIntegrationParams args) + public async Task ModifyGuildIntegrationAsync(ulong guildId, ulong integrationId, ModifyGuildIntegrationParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(integrationId, 0, nameof(integrationId)); @@ -602,74 +684,82 @@ namespace Discord.API Preconditions.AtLeast(args.ExpireBehavior, 0, nameof(args.ExpireBehavior)); Preconditions.AtLeast(args.ExpireGracePeriod, 0, nameof(args.ExpireGracePeriod)); - return await Send("PATCH", $"guilds/{guildId}/integrations/{integrationId}", args).ConfigureAwait(false); + return await SendAsync("PATCH", $"guilds/{guildId}/integrations/{integrationId}", args, options: options).ConfigureAwait(false); } - public async Task SyncGuildIntegration(ulong guildId, ulong integrationId) + public async Task SyncGuildIntegrationAsync(ulong guildId, ulong integrationId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(integrationId, 0, nameof(integrationId)); - return await Send("POST", $"guilds/{guildId}/integrations/{integrationId}/sync").ConfigureAwait(false); + return await SendAsync("POST", $"guilds/{guildId}/integrations/{integrationId}/sync", options: options).ConfigureAwait(false); } //Guild Invites - public async Task GetInvite(string inviteIdOrXkcd) + public async Task GetInviteAsync(string inviteIdOrXkcd, RequestOptions options = null) { Preconditions.NotNullOrEmpty(inviteIdOrXkcd, nameof(inviteIdOrXkcd)); + //Remove trailing slash + if (inviteIdOrXkcd[inviteIdOrXkcd.Length - 1] == '/') + inviteIdOrXkcd = inviteIdOrXkcd.Substring(0, inviteIdOrXkcd.Length - 1); + //Remove leading URL + int index = inviteIdOrXkcd.LastIndexOf('/'); + if (index >= 0) + inviteIdOrXkcd = inviteIdOrXkcd.Substring(index + 1); + try { - return await Send("GET", $"invites/{inviteIdOrXkcd}").ConfigureAwait(false); + return await SendAsync("GET", $"invites/{inviteIdOrXkcd}", options: options).ConfigureAwait(false); } catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task> GetGuildInvites(ulong guildId) + public async Task> GetGuildInvitesAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send>("GET", $"guilds/{guildId}/invites").ConfigureAwait(false); + return await SendAsync>("GET", $"guilds/{guildId}/invites", options: options).ConfigureAwait(false); } - public async Task GetChannelInvites(ulong channelId) + public async Task GetChannelInvitesAsync(ulong channelId, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); - return await Send("GET", $"channels/{channelId}/invites").ConfigureAwait(false); + return await SendAsync("GET", $"channels/{channelId}/invites", options: options).ConfigureAwait(false); } - public async Task CreateChannelInvite(ulong channelId, CreateChannelInviteParams args) + public async Task CreateChannelInviteAsync(ulong channelId, CreateChannelInviteParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotNull(args, nameof(args)); Preconditions.AtLeast(args.MaxAge, 0, nameof(args.MaxAge)); Preconditions.AtLeast(args.MaxUses, 0, nameof(args.MaxUses)); - return await Send("POST", $"channels/{channelId}/invites", args).ConfigureAwait(false); + return await SendAsync("POST", $"channels/{channelId}/invites", args, options: options).ConfigureAwait(false); } - public async Task DeleteInvite(string inviteCode) + public async Task DeleteInviteAsync(string inviteCode, RequestOptions options = null) { Preconditions.NotNullOrEmpty(inviteCode, nameof(inviteCode)); - return await Send("DELETE", $"invites/{inviteCode}").ConfigureAwait(false); + return await SendAsync("DELETE", $"invites/{inviteCode}", options: options).ConfigureAwait(false); } - public async Task AcceptInvite(string inviteCode) + public async Task AcceptInviteAsync(string inviteCode, RequestOptions options = null) { Preconditions.NotNullOrEmpty(inviteCode, nameof(inviteCode)); - await Send("POST", $"invites/{inviteCode}").ConfigureAwait(false); + await SendAsync("POST", $"invites/{inviteCode}", options: options).ConfigureAwait(false); } //Guild Members - public async Task GetGuildMember(ulong guildId, ulong userId) + public async Task GetGuildMemberAsync(ulong guildId, ulong userId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(userId, 0, nameof(userId)); try { - return await Send("GET", $"guilds/{guildId}/members/{userId}").ConfigureAwait(false); + return await SendAsync("GET", $"guilds/{guildId}/members/{userId}", options: options).ConfigureAwait(false); } catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task> GetGuildMembers(ulong guildId, GetGuildMembersParams args) + public async Task> GetGuildMembersAsync(ulong guildId, GetGuildMembersParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotNull(args, nameof(args)); @@ -689,7 +779,7 @@ namespace Discord.API { int runLimit = (limit >= DiscordConfig.MaxUsersPerBatch) ? DiscordConfig.MaxUsersPerBatch : limit; string endpoint = $"guilds/{guildId}/members?limit={runLimit}&offset={offset}"; - var models = await Send("GET", endpoint).ConfigureAwait(false); + var models = await SendAsync("GET", endpoint, options: options).ConfigureAwait(false); //Was this an empty batch? if (models.Length == 0) break; @@ -704,49 +794,49 @@ namespace Discord.API } if (result.Count > 1) - return result.SelectMany(x => x); + return result.SelectMany(x => x).ToImmutableArray(); else if (result.Count == 1) return result[0]; else - return Array.Empty(); + return ImmutableArray.Create(); } - public async Task RemoveGuildMember(ulong guildId, ulong userId) + public async Task RemoveGuildMemberAsync(ulong guildId, ulong userId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(userId, 0, nameof(userId)); - await Send("DELETE", $"guilds/{guildId}/members/{userId}").ConfigureAwait(false); + await SendAsync("DELETE", $"guilds/{guildId}/members/{userId}", options: options).ConfigureAwait(false); } - public async Task ModifyGuildMember(ulong guildId, ulong userId, ModifyGuildMemberParams args) + public async Task ModifyGuildMemberAsync(ulong guildId, ulong userId, ModifyGuildMemberParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(userId, 0, nameof(userId)); Preconditions.NotNull(args, nameof(args)); - await Send("PATCH", $"guilds/{guildId}/members/{userId}", args, GuildBucket.ModifyMember, guildId).ConfigureAwait(false); + await SendAsync("PATCH", $"guilds/{guildId}/members/{userId}", args, GuildBucket.ModifyMember, guildId, options: options).ConfigureAwait(false); } //Guild Roles - public async Task> GetGuildRoles(ulong guildId) + public async Task> GetGuildRolesAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send>("GET", $"guilds/{guildId}/roles").ConfigureAwait(false); + return await SendAsync>("GET", $"guilds/{guildId}/roles", options: options).ConfigureAwait(false); } - public async Task CreateGuildRole(ulong guildId) + public async Task CreateGuildRoleAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send("POST", $"guilds/{guildId}/roles").ConfigureAwait(false); + return await SendAsync("POST", $"guilds/{guildId}/roles", options: options).ConfigureAwait(false); } - public async Task DeleteGuildRole(ulong guildId, ulong roleId) + public async Task DeleteGuildRoleAsync(ulong guildId, ulong roleId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(roleId, 0, nameof(roleId)); - await Send("DELETE", $"guilds/{guildId}/roles/{roleId}").ConfigureAwait(false); + await SendAsync("DELETE", $"guilds/{guildId}/roles/{roleId}", options: options).ConfigureAwait(false); } - public async Task ModifyGuildRole(ulong guildId, ulong roleId, ModifyGuildRoleParams args) + public async Task ModifyGuildRoleAsync(ulong guildId, ulong roleId, ModifyGuildRoleParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotEqual(roleId, 0, nameof(roleId)); @@ -755,9 +845,9 @@ namespace Discord.API Preconditions.NotNullOrEmpty(args.Name, nameof(args.Name)); Preconditions.AtLeast(args.Position, 0, nameof(args.Position)); - return await Send("PATCH", $"guilds/{guildId}/roles/{roleId}", args).ConfigureAwait(false); + return await SendAsync("PATCH", $"guilds/{guildId}/roles/{roleId}", args, options: options).ConfigureAwait(false); } - public async Task> ModifyGuildRoles(ulong guildId, IEnumerable args) + public async Task> ModifyGuildRolesAsync(ulong guildId, IEnumerable args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); Preconditions.NotNull(args, nameof(args)); @@ -766,16 +856,27 @@ namespace Discord.API switch (roles.Length) { case 0: - return Array.Empty(); + return ImmutableArray.Create(); case 1: - return ImmutableArray.Create(await ModifyGuildRole(guildId, roles[0].Id, roles[0]).ConfigureAwait(false)); + return ImmutableArray.Create(await ModifyGuildRoleAsync(guildId, roles[0].Id, roles[0]).ConfigureAwait(false)); default: - return await Send>("PATCH", $"guilds/{guildId}/roles", args).ConfigureAwait(false); + return await SendAsync>("PATCH", $"guilds/{guildId}/roles", args, options: options).ConfigureAwait(false); } } //Messages - public async Task> GetChannelMessages(ulong channelId, GetChannelMessagesParams args) + public async Task GetChannelMessageAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + + try + { + return await SendAsync("GET", $"channels/{channelId}/messages/{messageId}", options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } + } + public async Task> GetChannelMessagesAsync(ulong channelId, GetChannelMessagesParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotNull(args, nameof(args)); @@ -783,7 +884,21 @@ namespace Discord.API int limit = args.Limit; ulong? relativeId = args.RelativeMessageId.IsSpecified ? args.RelativeMessageId.Value : (ulong?)null; - string relativeDir = args.RelativeDirection == Direction.After ? "after" : "before"; + string relativeDir; + + switch (args.RelativeDirection) + { + case Direction.Before: + default: + relativeDir = "before"; + break; + case Direction.After: + relativeDir = "after"; + break; + case Direction.Around: + relativeDir = "around"; + break; + } int runs = (limit + DiscordConfig.MaxMessagesPerBatch - 1) / DiscordConfig.MaxMessagesPerBatch; int lastRunCount = limit - (runs - 1) * DiscordConfig.MaxMessagesPerBatch; @@ -798,7 +913,7 @@ namespace Discord.API endpoint = $"channels/{channelId}/messages?limit={runCount}&{relativeDir}={relativeId}"; else endpoint = $"channels/{channelId}/messages?limit={runCount}"; - var models = await Send("GET", endpoint).ConfigureAwait(false); + var models = await SendAsync("GET", endpoint, options: options).ConfigureAwait(false); //Was this an empty batch? if (models.Length == 0) break; @@ -813,26 +928,26 @@ namespace Discord.API if (i > 1) { if (args.RelativeDirection == Direction.Before) - return result.Take(i).SelectMany(x => x); + return result.Take(i).SelectMany(x => x).ToImmutableArray(); else - return result.Take(i).Reverse().SelectMany(x => x); + return result.Take(i).Reverse().SelectMany(x => x).ToImmutableArray(); } else if (i == 1) return result[0]; else - return Array.Empty(); + return ImmutableArray.Create(); } - public Task CreateMessage(ulong guildId, ulong channelId, CreateMessageParams args) + public Task CreateMessageAsync(ulong guildId, ulong channelId, CreateMessageParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return CreateMessageInternal(guildId, channelId, args); + return CreateMessageInternalAsync(guildId, channelId, args); } - public Task CreateDMMessage(ulong channelId, CreateMessageParams args) + public Task CreateDMMessageAsync(ulong channelId, CreateMessageParams args, RequestOptions options = null) { - return CreateMessageInternal(0, channelId, args); + return CreateMessageInternalAsync(0, channelId, args); } - public async Task CreateMessageInternal(ulong guildId, ulong channelId, CreateMessageParams args) + public async Task CreateMessageInternalAsync(ulong guildId, ulong channelId, CreateMessageParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotNull(args, nameof(args)); @@ -841,21 +956,21 @@ namespace Discord.API throw new ArgumentException($"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", nameof(args.Content)); if (guildId != 0) - return await Send("POST", $"channels/{channelId}/messages", args, GuildBucket.SendEditMessage, guildId).ConfigureAwait(false); + return await SendAsync("POST", $"channels/{channelId}/messages", args, GuildBucket.SendEditMessage, guildId, options: options).ConfigureAwait(false); else - return await Send("POST", $"channels/{channelId}/messages", args, GlobalBucket.DirectMessage).ConfigureAwait(false); + return await SendAsync("POST", $"channels/{channelId}/messages", args, GlobalBucket.DirectMessage, options: options).ConfigureAwait(false); } - public Task UploadFile(ulong guildId, ulong channelId, Stream file, UploadFileParams args) + public Task UploadFileAsync(ulong guildId, ulong channelId, Stream file, UploadFileParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return UploadFileInternal(guildId, channelId, file, args); + return UploadFileInternalAsync(guildId, channelId, file, args); } - public Task UploadDMFile(ulong channelId, Stream file, UploadFileParams args) + public Task UploadDMFileAsync(ulong channelId, Stream file, UploadFileParams args, RequestOptions options = null) { - return UploadFileInternal(0, channelId, file, args); + return UploadFileInternalAsync(0, channelId, file, args); } - private async Task UploadFileInternal(ulong guildId, ulong channelId, Stream file, UploadFileParams args) + private async Task UploadFileInternalAsync(ulong guildId, ulong channelId, Stream file, UploadFileParams args, RequestOptions options = null) { Preconditions.NotNull(args, nameof(args)); Preconditions.NotEqual(channelId, 0, nameof(channelId)); @@ -867,73 +982,75 @@ namespace Discord.API } if (guildId != 0) - return await Send("POST", $"channels/{channelId}/messages", file, args.ToDictionary(), GuildBucket.SendEditMessage, guildId).ConfigureAwait(false); + return await SendAsync("POST", $"channels/{channelId}/messages", file, args.ToDictionary(), GuildBucket.SendEditMessage, guildId, options: options).ConfigureAwait(false); else - return await Send("POST", $"channels/{channelId}/messages", file, args.ToDictionary(), GlobalBucket.DirectMessage).ConfigureAwait(false); + return await SendAsync("POST", $"channels/{channelId}/messages", file, args.ToDictionary(), GlobalBucket.DirectMessage, options: options).ConfigureAwait(false); } - public Task DeleteMessage(ulong guildId, ulong channelId, ulong messageId) + public Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return DeleteMessageInternal(guildId, channelId, messageId); + return DeleteMessageInternalAsync(guildId, channelId, messageId); } - public Task DeleteDMMessage(ulong channelId, ulong messageId) + public Task DeleteDMMessageAsync(ulong channelId, ulong messageId, RequestOptions options = null) { - return DeleteMessageInternal(0, channelId, messageId); + return DeleteMessageInternalAsync(0, channelId, messageId); } - private async Task DeleteMessageInternal(ulong guildId, ulong channelId, ulong messageId) + private async Task DeleteMessageInternalAsync(ulong guildId, ulong channelId, ulong messageId, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotEqual(messageId, 0, nameof(messageId)); if (guildId != 0) - await Send("DELETE", $"channels/{channelId}/messages/{messageId}", GuildBucket.DeleteMessage, guildId).ConfigureAwait(false); + await SendAsync("DELETE", $"channels/{channelId}/messages/{messageId}", GuildBucket.DeleteMessage, guildId, options: options).ConfigureAwait(false); else - await Send("DELETE", $"channels/{channelId}/messages/{messageId}").ConfigureAwait(false); + await SendAsync("DELETE", $"channels/{channelId}/messages/{messageId}", options: options).ConfigureAwait(false); } - public Task DeleteMessages(ulong guildId, ulong channelId, DeleteMessagesParam args) + public Task DeleteMessagesAsync(ulong guildId, ulong channelId, DeleteMessagesParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return DeleteMessagesInternal(guildId, channelId, args); + return DeleteMessagesInternalAsync(guildId, channelId, args); } - public Task DeleteDMMessages(ulong channelId, DeleteMessagesParam args) + public Task DeleteDMMessagesAsync(ulong channelId, DeleteMessagesParams args, RequestOptions options = null) { - return DeleteMessagesInternal(0, channelId, args); + return DeleteMessagesInternalAsync(0, channelId, args); } - private async Task DeleteMessagesInternal(ulong guildId, ulong channelId, DeleteMessagesParam args) + private async Task DeleteMessagesInternalAsync(ulong guildId, ulong channelId, DeleteMessagesParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotNull(args, nameof(args)); + + var messageIds = args.MessageIds?.ToArray(); Preconditions.NotNull(args.MessageIds, nameof(args.MessageIds)); + Preconditions.AtMost(messageIds.Length, 100, nameof(messageIds.Length)); - var messageIds = args.MessageIds.ToArray(); switch (messageIds.Length) { case 0: return; case 1: - await DeleteMessageInternal(guildId, channelId, messageIds[0]).ConfigureAwait(false); + await DeleteMessageInternalAsync(guildId, channelId, messageIds[0]).ConfigureAwait(false); break; default: if (guildId != 0) - await Send("POST", $"channels/{channelId}/messages/bulk_delete", args, GuildBucket.DeleteMessages, guildId).ConfigureAwait(false); + await SendAsync("POST", $"channels/{channelId}/messages/bulk_delete", args, GuildBucket.DeleteMessages, guildId, options: options).ConfigureAwait(false); else - await Send("POST", $"channels/{channelId}/messages/bulk_delete", args).ConfigureAwait(false); + await SendAsync("POST", $"channels/{channelId}/messages/bulk_delete", args, options: options).ConfigureAwait(false); break; } } - public Task ModifyMessage(ulong guildId, ulong channelId, ulong messageId, ModifyMessageParams args) + public Task ModifyMessageAsync(ulong guildId, ulong channelId, ulong messageId, ModifyMessageParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return ModifyMessageInternal(guildId, channelId, messageId, args); + return ModifyMessageInternalAsync(guildId, channelId, messageId, args); } - public Task ModifyDMMessage(ulong channelId, ulong messageId, ModifyMessageParams args) + public Task ModifyDMMessageAsync(ulong channelId, ulong messageId, ModifyMessageParams args, RequestOptions options = null) { - return ModifyMessageInternal(0, channelId, messageId, args); + return ModifyMessageInternalAsync(0, channelId, messageId, args); } - private async Task ModifyMessageInternal(ulong guildId, ulong channelId, ulong messageId, ModifyMessageParams args) + private async Task ModifyMessageInternalAsync(ulong guildId, ulong channelId, ulong messageId, ModifyMessageParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotEqual(messageId, 0, nameof(messageId)); @@ -946,104 +1063,104 @@ namespace Discord.API } if (guildId != 0) - return await Send("PATCH", $"channels/{channelId}/messages/{messageId}", args, GuildBucket.SendEditMessage, guildId).ConfigureAwait(false); + return await SendAsync("PATCH", $"channels/{channelId}/messages/{messageId}", args, GuildBucket.SendEditMessage, guildId, options: options).ConfigureAwait(false); else - return await Send("PATCH", $"channels/{channelId}/messages/{messageId}", args).ConfigureAwait(false); + return await SendAsync("PATCH", $"channels/{channelId}/messages/{messageId}", args, options: options).ConfigureAwait(false); } - public async Task AckMessage(ulong channelId, ulong messageId) + public async Task AckMessageAsync(ulong channelId, ulong messageId, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotEqual(messageId, 0, nameof(messageId)); - await Send("POST", $"channels/{channelId}/messages/{messageId}/ack").ConfigureAwait(false); + await SendAsync("POST", $"channels/{channelId}/messages/{messageId}/ack", options: options).ConfigureAwait(false); } - public async Task TriggerTypingIndicator(ulong channelId) + public async Task TriggerTypingIndicatorAsync(ulong channelId, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); - await Send("POST", $"channels/{channelId}/typing").ConfigureAwait(false); + await SendAsync("POST", $"channels/{channelId}/typing", options: options).ConfigureAwait(false); } //Users - public async Task GetUser(ulong userId) + public async Task GetUserAsync(ulong userId, RequestOptions options = null) { Preconditions.NotEqual(userId, 0, nameof(userId)); try { - return await Send("GET", $"users/{userId}").ConfigureAwait(false); + return await SendAsync("GET", $"users/{userId}", options: options).ConfigureAwait(false); } catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task GetUser(string username, ushort discriminator) + public async Task GetUserAsync(string username, string discriminator, RequestOptions options = null) { Preconditions.NotNullOrEmpty(username, nameof(username)); - + Preconditions.NotNullOrEmpty(discriminator, nameof(discriminator)); + try { - var models = await QueryUsers($"{username}#{discriminator}", 1).ConfigureAwait(false); + var models = await QueryUsersAsync($"{username}#{discriminator}", 1, options: options).ConfigureAwait(false); return models.FirstOrDefault(); } catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task> QueryUsers(string query, int limit) + public async Task> QueryUsersAsync(string query, int limit, RequestOptions options = null) { Preconditions.NotNullOrEmpty(query, nameof(query)); Preconditions.AtLeast(limit, 0, nameof(limit)); - return await Send>("GET", $"users?q={Uri.EscapeDataString(query)}&limit={limit}").ConfigureAwait(false); + return await SendAsync>("GET", $"users?q={Uri.EscapeDataString(query)}&limit={limit}", options: options).ConfigureAwait(false); } //Current User/DMs - public async Task GetCurrentUser() + public async Task GetSelfAsync(RequestOptions options = null) { - return await Send("GET", "users/@me").ConfigureAwait(false); + return await SendAsync("GET", "users/@me", options: options).ConfigureAwait(false); } - public async Task> GetCurrentUserConnections() + public async Task> GetMyConnectionsAsync(RequestOptions options = null) { - return await Send>("GET", "users/@me/connections").ConfigureAwait(false); + return await SendAsync>("GET", "users/@me/connections", options: options).ConfigureAwait(false); } - public async Task> GetCurrentUserDMs() + public async Task> GetMyDMsAsync(RequestOptions options = null) { - return await Send>("GET", "users/@me/channels").ConfigureAwait(false); + return await SendAsync>("GET", "users/@me/channels", options: options).ConfigureAwait(false); } - public async Task> GetCurrentUserGuilds() + public async Task> GetMyGuildsAsync(RequestOptions options = null) { - return await Send>("GET", "users/@me/guilds").ConfigureAwait(false); + return await SendAsync>("GET", "users/@me/guilds", options: options).ConfigureAwait(false); } - public async Task ModifyCurrentUser(ModifyCurrentUserParams args) + public async Task ModifySelfAsync(ModifyCurrentUserParams args, RequestOptions options = null) { Preconditions.NotNull(args, nameof(args)); - Preconditions.NotNullOrEmpty(args.Email, nameof(args.Email)); Preconditions.NotNullOrEmpty(args.Username, nameof(args.Username)); - return await Send("PATCH", "users/@me", args).ConfigureAwait(false); + return await SendAsync("PATCH", "users/@me", args, options: options).ConfigureAwait(false); } - public async Task ModifyCurrentUserNick(ulong guildId, ModifyCurrentUserNickParams args) + public async Task ModifyMyNickAsync(ulong guildId, ModifyCurrentUserNickParams args, RequestOptions options = null) { Preconditions.NotNull(args, nameof(args)); Preconditions.NotEmpty(args.Nickname, nameof(args.Nickname)); - await Send("PATCH", $"guilds/{guildId}/members/@me/nick", args).ConfigureAwait(false); + await SendAsync("PATCH", $"guilds/{guildId}/members/@me/nick", args, options: options).ConfigureAwait(false); } - public async Task CreateDMChannel(CreateDMChannelParams args) + public async Task CreateDMChannelAsync(CreateDMChannelParams args, RequestOptions options = null) { Preconditions.NotNull(args, nameof(args)); - Preconditions.NotEqual(args.RecipientId, 0, nameof(args.RecipientId)); + Preconditions.GreaterThan(args.RecipientId, 0, nameof(args.Recipient)); - return await Send("POST", $"users/@me/channels", args).ConfigureAwait(false); + return await SendAsync("POST", $"users/@me/channels", args, options: options).ConfigureAwait(false); } //Voice Regions - public async Task> GetVoiceRegions() + public async Task> GetVoiceRegionsAsync(RequestOptions options = null) { - return await Send>("GET", "voice/regions").ConfigureAwait(false); + return await SendAsync>("GET", "voice/regions", options: options).ConfigureAwait(false); } - public async Task> GetGuildVoiceRegions(ulong guildId) + public async Task> GetGuildVoiceRegionsAsync(ulong guildId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); - return await Send>("GET", $"guilds/{guildId}/regions").ConfigureAwait(false); + return await SendAsync>("GET", $"guilds/{guildId}/regions", options: options).ConfigureAwait(false); } //Helpers diff --git a/src/Discord.Net/API/DiscordVoiceAPIClient.cs b/src/Discord.Net/API/DiscordVoiceAPIClient.cs new file mode 100644 index 000000000..2b278d903 --- /dev/null +++ b/src/Discord.Net/API/DiscordVoiceAPIClient.cs @@ -0,0 +1,254 @@ +using Discord.API; +using Discord.API.Voice; +using Discord.Net.Converters; +using Discord.Net.WebSockets; +using Newtonsoft.Json; +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Net.Sockets; +using System.Net; + +namespace Discord.Audio +{ + public class DiscordVoiceAPIClient + { + public const int MaxBitrate = 128; + public const string Mode = "xsalsa20_poly1305"; + + public event Func SentRequest { add { _sentRequestEvent.Add(value); } remove { _sentRequestEvent.Remove(value); } } + private readonly AsyncEvent> _sentRequestEvent = new AsyncEvent>(); + public event Func SentGatewayMessage { add { _sentGatewayMessageEvent.Add(value); } remove { _sentGatewayMessageEvent.Remove(value); } } + private readonly AsyncEvent> _sentGatewayMessageEvent = new AsyncEvent>(); + public event Func SentDiscovery { add { _sentDiscoveryEvent.Add(value); } remove { _sentDiscoveryEvent.Remove(value); } } + private readonly AsyncEvent> _sentDiscoveryEvent = new AsyncEvent>(); + + public event Func ReceivedEvent { add { _receivedEvent.Add(value); } remove { _receivedEvent.Remove(value); } } + private readonly AsyncEvent> _receivedEvent = new AsyncEvent>(); + public event Func ReceivedPacket { add { _receivedPacketEvent.Add(value); } remove { _receivedPacketEvent.Remove(value); } } + private readonly AsyncEvent> _receivedPacketEvent = new AsyncEvent>(); + public event Func Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + + private readonly JsonSerializer _serializer; + private readonly IWebSocketClient _webSocketClient; + private readonly SemaphoreSlim _connectionLock; + private CancellationTokenSource _connectCancelToken; + private UdpClient _udp; + private IPEndPoint _udpEndpoint; + private Task _udpRecieveTask; + private bool _isDisposed; + + public ulong GuildId { get; } + public ConnectionState ConnectionState { get; private set; } + + internal DiscordVoiceAPIClient(ulong guildId, WebSocketProvider webSocketProvider, JsonSerializer serializer = null) + { + GuildId = guildId; + _connectionLock = new SemaphoreSlim(1, 1); + _udp = new UdpClient(new IPEndPoint(IPAddress.Any, 0)); + + _webSocketClient = webSocketProvider(); + //_gatewayClient.SetHeader("user-agent", DiscordConfig.UserAgent); (Causes issues in .Net 4.6+) + _webSocketClient.BinaryMessage += async (data, index, count) => + { + using (var compressed = new MemoryStream(data, index + 2, count - 2)) + using (var decompressed = new MemoryStream()) + { + using (var zlib = new DeflateStream(compressed, CompressionMode.Decompress)) + zlib.CopyTo(decompressed); + decompressed.Position = 0; + using (var reader = new StreamReader(decompressed)) + { + var msg = JsonConvert.DeserializeObject(reader.ReadToEnd()); + await _receivedEvent.InvokeAsync((VoiceOpCode)msg.Operation, msg.Payload).ConfigureAwait(false); + } + } + }; + _webSocketClient.TextMessage += async text => + { + var msg = JsonConvert.DeserializeObject(text); + await _receivedEvent.InvokeAsync((VoiceOpCode)msg.Operation, msg.Payload).ConfigureAwait(false); + }; + _webSocketClient.Closed += async ex => + { + await DisconnectAsync().ConfigureAwait(false); + await _disconnectedEvent.InvokeAsync(ex).ConfigureAwait(false); + }; + + _serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _connectCancelToken?.Dispose(); + (_webSocketClient as IDisposable)?.Dispose(); + } + _isDisposed = true; + } + } + public void Dispose() => Dispose(true); + + public async Task SendAsync(VoiceOpCode opCode, object payload, RequestOptions options = null) + { + byte[] bytes = null; + payload = new WebSocketMessage { Operation = (int)opCode, Payload = payload }; + if (payload != null) + bytes = Encoding.UTF8.GetBytes(SerializeJson(payload)); + await _webSocketClient.SendAsync(bytes, 0, bytes.Length, true).ConfigureAwait(false); + await _sentGatewayMessageEvent.InvokeAsync(opCode); + } + public async Task SendAsync(byte[] data, int bytes) + { + if (_udpEndpoint != null) + { + await _udp.SendAsync(data, bytes, _udpEndpoint).ConfigureAwait(false); + await _sentDiscoveryEvent.InvokeAsync().ConfigureAwait(false); + } + } + + //WebSocket + public async Task SendHeartbeatAsync(RequestOptions options = null) + { + await SendAsync(VoiceOpCode.Heartbeat, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), options: options).ConfigureAwait(false); + } + public async Task SendIdentityAsync(ulong userId, string sessionId, string token) + { + await SendAsync(VoiceOpCode.Identify, new IdentifyParams + { + GuildId = GuildId, + UserId = userId, + SessionId = sessionId, + Token = token + }); + } + public async Task SendSelectProtocol(string externalIp, int externalPort) + { + await SendAsync(VoiceOpCode.SelectProtocol, new SelectProtocolParams + { + Protocol = "udp", + Data = new UdpProtocolInfo + { + Address = externalIp, + Port = externalPort, + Mode = Mode + } + }); + } + public async Task SendSetSpeaking(bool value) + { + await SendAsync(VoiceOpCode.Speaking, new SpeakingParams + { + IsSpeaking = value, + Delay = 0 + }); + } + + public async Task ConnectAsync(string url) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync(url).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task ConnectInternalAsync(string url) + { + ConnectionState = ConnectionState.Connecting; + try + { + _connectCancelToken = new CancellationTokenSource(); + _webSocketClient.SetCancelToken(_connectCancelToken.Token); + await _webSocketClient.ConnectAsync(url).ConfigureAwait(false); + _udpRecieveTask = ReceiveAsync(_connectCancelToken.Token); + + ConnectionState = ConnectionState.Connected; + } + catch (Exception) + { + await DisconnectInternalAsync().ConfigureAwait(false); + throw; + } + } + + public async Task DisconnectAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task DisconnectInternalAsync() + { + if (ConnectionState == ConnectionState.Disconnected) return; + ConnectionState = ConnectionState.Disconnecting; + + try { _connectCancelToken?.Cancel(false); } + catch { } + + //Wait for tasks to complete + await _udpRecieveTask.ConfigureAwait(false); + + await _webSocketClient.DisconnectAsync().ConfigureAwait(false); + + ConnectionState = ConnectionState.Disconnected; + } + + //Udp + public async Task SendDiscoveryAsync(uint ssrc) + { + var packet = new byte[70]; + packet[0] = (byte)(ssrc >> 24); + packet[1] = (byte)(ssrc >> 16); + packet[2] = (byte)(ssrc >> 8); + packet[3] = (byte)(ssrc >> 0); + await SendAsync(packet, 70).ConfigureAwait(false); + } + + public void SetUdpEndpoint(IPEndPoint endpoint) + { + _udpEndpoint = endpoint; + } + private async Task ReceiveAsync(CancellationToken cancelToken) + { + var closeTask = Task.Delay(-1, cancelToken); + while (!cancelToken.IsCancellationRequested) + { + var receiveTask = _udp.ReceiveAsync(); + var task = await Task.WhenAny(closeTask, receiveTask).ConfigureAwait(false); + if (task == closeTask) + break; + + await _receivedPacketEvent.InvokeAsync(receiveTask.Result.Buffer).ConfigureAwait(false); + } + } + + //Helpers + private static double ToMilliseconds(Stopwatch stopwatch) => Math.Round((double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2); + private string SerializeJson(object value) + { + var sb = new StringBuilder(256); + using (TextWriter text = new StringWriter(sb, CultureInfo.InvariantCulture)) + using (JsonWriter writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, value); + return sb.ToString(); + } + private T DeserializeJson(Stream jsonStream) + { + using (TextReader text = new StreamReader(jsonStream)) + using (JsonReader reader = new JsonTextReader(text)) + return _serializer.Deserialize(reader); + } + } +} diff --git a/src/Discord.Net/API/Gateway/ExtendedGuild.cs b/src/Discord.Net/API/Gateway/ExtendedGuild.cs new file mode 100644 index 000000000..a267295cb --- /dev/null +++ b/src/Discord.Net/API/Gateway/ExtendedGuild.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Gateway +{ + public class ExtendedGuild : Guild + { + [JsonProperty("unavailable")] + public bool? Unavailable { get; set; } + [JsonProperty("member_count")] + public int MemberCount { get; set; } + [JsonProperty("large")] + public bool Large { get; set; } + + [JsonProperty("presences")] + public Presence[] Presences { get; set; } + [JsonProperty("members")] + public GuildMember[] Members { get; set; } + [JsonProperty("channels")] + public Channel[] Channels { get; set; } + [JsonProperty("joined_at")] + public DateTimeOffset JoinedAt { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/GatewayOpCodes.cs b/src/Discord.Net/API/Gateway/GatewayOpCode.cs similarity index 53% rename from src/Discord.Net/API/Gateway/GatewayOpCodes.cs rename to src/Discord.Net/API/Gateway/GatewayOpCode.cs index 82fbf51f3..3c2a3382b 100644 --- a/src/Discord.Net/API/Gateway/GatewayOpCodes.cs +++ b/src/Discord.Net/API/Gateway/GatewayOpCode.cs @@ -1,6 +1,6 @@ namespace Discord.API.Gateway { - public enum GatewayOpCodes : byte + public enum GatewayOpCode : byte { /// C←S - Used to send most events. Dispatch = 0, @@ -12,13 +12,21 @@ StatusUpdate = 3, /// C→S - Used to join a particular voice channel. VoiceStateUpdate = 4, - /// C→S - Used to ensure the server's voice server is alive. Only send this if voice connection fails or suddenly drops. + /// C→S - Used to ensure the guild's voice server is alive. VoiceServerPing = 5, /// C→S - Used to resume a connection after a redirect occurs. Resume = 6, /// C←S - Used to notify a client that they must reconnect to another gateway. Reconnect = 7, - /// C→S - Used to request all members that were withheld by large_threshold - RequestGuildMembers = 8 + /// C→S - Used to request members that were withheld by large_threshold + RequestGuildMembers = 8, + /// C←S - Used to notify the client that their session has expired and cannot be resumed. + InvalidSession = 9, + /// C←S - Used to provide information to the client immediately on connection. + Hello = 10, + /// C←S - Used to reply to a client's heartbeat. + HeartbeatAck = 11, + /// C→S - Used to request presence updates from particular guilds. + GuildSync = 12 } } diff --git a/src/Discord.Net/API/Gateway/GuildBanEvent.cs b/src/Discord.Net/API/Gateway/GuildBanEvent.cs new file mode 100644 index 000000000..5ad7534a2 --- /dev/null +++ b/src/Discord.Net/API/Gateway/GuildBanEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class GuildBanEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("user")] + public User User { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/GuildEmojiUpdateEvent.cs b/src/Discord.Net/API/Gateway/GuildEmojiUpdateEvent.cs new file mode 100644 index 000000000..13f083d40 --- /dev/null +++ b/src/Discord.Net/API/Gateway/GuildEmojiUpdateEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class GuildEmojiUpdateEvent + { + [JsonProperty("guild_id")] + public ulong GuildId; + [JsonProperty("emojis")] + public Emoji[] Emojis; + } +} diff --git a/src/Discord.Net/API/Gateway/GuildMemberAddEvent.cs b/src/Discord.Net/API/Gateway/GuildMemberAddEvent.cs new file mode 100644 index 000000000..3676439d4 --- /dev/null +++ b/src/Discord.Net/API/Gateway/GuildMemberAddEvent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class GuildMemberAddEvent : GuildMember + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/GuildMemberRemoveEvent.cs b/src/Discord.Net/API/Gateway/GuildMemberRemoveEvent.cs new file mode 100644 index 000000000..2916c5a91 --- /dev/null +++ b/src/Discord.Net/API/Gateway/GuildMemberRemoveEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class GuildMemberRemoveEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("user")] + public User User { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/GuildMemberUpdateEvent.cs b/src/Discord.Net/API/Gateway/GuildMemberUpdateEvent.cs new file mode 100644 index 000000000..8221b1199 --- /dev/null +++ b/src/Discord.Net/API/Gateway/GuildMemberUpdateEvent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class GuildMemberUpdateEvent : GuildMember + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/GuildRoleCreateEvent.cs b/src/Discord.Net/API/Gateway/GuildRoleCreateEvent.cs index f05543bf6..5753a638b 100644 --- a/src/Discord.Net/API/Gateway/GuildRoleCreateEvent.cs +++ b/src/Discord.Net/API/Gateway/GuildRoleCreateEvent.cs @@ -7,6 +7,6 @@ namespace Discord.API.Gateway [JsonProperty("guild_id")] public ulong GuildId { get; set; } [JsonProperty("role")] - public Role Data { get; set; } + public Role Role { get; set; } } } diff --git a/src/Discord.Net/API/Gateway/GuildRoleDeleteEvent.cs b/src/Discord.Net/API/Gateway/GuildRoleDeleteEvent.cs new file mode 100644 index 000000000..8e3b1fc37 --- /dev/null +++ b/src/Discord.Net/API/Gateway/GuildRoleDeleteEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class GuildRoleDeleteEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("role_id")] + public ulong RoleId { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/GuildRoleUpdateEvent.cs b/src/Discord.Net/API/Gateway/GuildRoleUpdateEvent.cs index 345154432..9e88b5de8 100644 --- a/src/Discord.Net/API/Gateway/GuildRoleUpdateEvent.cs +++ b/src/Discord.Net/API/Gateway/GuildRoleUpdateEvent.cs @@ -7,6 +7,6 @@ namespace Discord.API.Gateway [JsonProperty("guild_id")] public ulong GuildId { get; set; } [JsonProperty("role")] - public Role Data { get; set; } + public Role Role { get; set; } } } diff --git a/src/Discord.Net/API/Gateway/GuildSyncEvent.cs b/src/Discord.Net/API/Gateway/GuildSyncEvent.cs new file mode 100644 index 000000000..ff290f23a --- /dev/null +++ b/src/Discord.Net/API/Gateway/GuildSyncEvent.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class GuildSyncEvent + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("large")] + public bool Large { get; set; } + + [JsonProperty("presences")] + public Presence[] Presences { get; set; } + [JsonProperty("members")] + public GuildMember[] Members { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/HelloEvent.cs b/src/Discord.Net/API/Gateway/HelloEvent.cs new file mode 100644 index 000000000..ed201a999 --- /dev/null +++ b/src/Discord.Net/API/Gateway/HelloEvent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class HelloEvent + { + [JsonProperty("heartbeat_interval")] + public int HeartbeatInterval { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/MessageDeleteBulkEvent.cs b/src/Discord.Net/API/Gateway/MessageDeleteBulkEvent.cs new file mode 100644 index 000000000..8e7951530 --- /dev/null +++ b/src/Discord.Net/API/Gateway/MessageDeleteBulkEvent.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API.Gateway +{ + public class MessageDeleteBulkEvent + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + [JsonProperty("ids")] + public IEnumerable Ids { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/ReadyEvent.cs b/src/Discord.Net/API/Gateway/ReadyEvent.cs index c0384c100..ba4f8756f 100644 --- a/src/Discord.Net/API/Gateway/ReadyEvent.cs +++ b/src/Discord.Net/API/Gateway/ReadyEvent.cs @@ -23,18 +23,15 @@ namespace Discord.API.Gateway [JsonProperty("read_state")] public ReadState[] ReadStates { get; set; } [JsonProperty("guilds")] - public Guild[] Guilds { get; set; } + public ExtendedGuild[] Guilds { get; set; } [JsonProperty("private_channels")] public Channel[] PrivateChannels { get; set; } - [JsonProperty("heartbeat_interval")] - public int HeartbeatInterval { get; set; } + [JsonProperty("relationships")] + public Relationship[] Relationships { get; set; } //Ignored - [JsonProperty("user_settings")] - public object UserSettings { get; set; } + /*[JsonProperty("user_settings")] [JsonProperty("user_guild_settings")] - public object UserGuildSettings { get; set; } - [JsonProperty("tutorial")] - public object Tutorial { get; set; } + [JsonProperty("tutorial")]*/ } } diff --git a/src/Discord.Net/API/Gateway/RequestMembersParams.cs b/src/Discord.Net/API/Gateway/RequestMembersParams.cs index ed6edc6ef..a0819c556 100644 --- a/src/Discord.Net/API/Gateway/RequestMembersParams.cs +++ b/src/Discord.Net/API/Gateway/RequestMembersParams.cs @@ -1,14 +1,19 @@ using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; namespace Discord.API.Gateway { public class RequestMembersParams { - [JsonProperty("guild_id")] - public ulong[] GuildId { get; set; } [JsonProperty("query")] public string Query { get; set; } [JsonProperty("limit")] public int Limit { get; set; } + + [JsonProperty("guild_id")] + public IEnumerable GuildIds { get; set; } + [JsonIgnore] + public IEnumerable Guilds { set { GuildIds = value.Select(x => x.Id); } } } } diff --git a/src/Discord.Net/API/Gateway/ResumeParams.cs b/src/Discord.Net/API/Gateway/ResumeParams.cs index ba4489336..b10e312f2 100644 --- a/src/Discord.Net/API/Gateway/ResumeParams.cs +++ b/src/Discord.Net/API/Gateway/ResumeParams.cs @@ -7,6 +7,6 @@ namespace Discord.API.Gateway [JsonProperty("session_id")] public string SessionId { get; set; } [JsonProperty("seq")] - public uint Sequence { get; set; } + public int Sequence { get; set; } } } diff --git a/src/Discord.Net/API/Gateway/StatusUpdateParams.cs b/src/Discord.Net/API/Gateway/StatusUpdateParams.cs new file mode 100644 index 000000000..29a525674 --- /dev/null +++ b/src/Discord.Net/API/Gateway/StatusUpdateParams.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class StatusUpdateParams + { + [JsonProperty("idle_since"), Int53] + public long? IdleSince { get; set; } + [JsonProperty("game")] + public Game Game { get; set; } + } +} diff --git a/src/Discord.Net/API/Gateway/UpdateVoiceParams.cs b/src/Discord.Net/API/Gateway/UpdateVoiceParams.cs deleted file mode 100644 index d72d63548..000000000 --- a/src/Discord.Net/API/Gateway/UpdateVoiceParams.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Newtonsoft.Json; - -namespace Discord.API.Gateway -{ - public class UpdateVoiceParams - { - [JsonProperty("guild_id")] - public ulong? GuildId { get; set; } - [JsonProperty("channel_id")] - public ulong? ChannelId { get; set; } - [JsonProperty("self_mute")] - public bool IsSelfMuted { get; set; } - [JsonProperty("self_deaf")] - public bool IsSelfDeafened { get; set; } - } -} diff --git a/src/Discord.Net/API/Gateway/VoiceStateUpdateParams.cs b/src/Discord.Net/API/Gateway/VoiceStateUpdateParams.cs new file mode 100644 index 000000000..6eb285cea --- /dev/null +++ b/src/Discord.Net/API/Gateway/VoiceStateUpdateParams.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + public class VoiceStateUpdateParams + { + [JsonProperty("self_mute")] + public bool SelfMute { get; set; } + [JsonProperty("self_deaf")] + public bool SelfDeaf { get; set; } + + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonIgnore] + public IGuild Guild { set { GuildId = value.Id; } } + [JsonProperty("channel_id")] + public ulong? ChannelId { get; set; } + [JsonIgnore] + public IChannel Channel { set { ChannelId = value?.Id; } } + } +} diff --git a/src/Discord.Net/API/IOptional.cs b/src/Discord.Net/API/IOptional.cs deleted file mode 100644 index 51d4f5271..000000000 --- a/src/Discord.Net/API/IOptional.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Discord.API -{ - public interface IOptional - { - object Value { get; } - bool IsSpecified { get; } - } -} diff --git a/src/Discord.Net/API/Rest/CreateDMChannelParams.cs b/src/Discord.Net/API/Rest/CreateDMChannelParams.cs index 9ce033783..779fd5dc9 100644 --- a/src/Discord.Net/API/Rest/CreateDMChannelParams.cs +++ b/src/Discord.Net/API/Rest/CreateDMChannelParams.cs @@ -6,5 +6,7 @@ namespace Discord.API.Rest { [JsonProperty("recipient_id")] public ulong RecipientId { get; set; } + [JsonIgnore] + public IUser Recipient { set { RecipientId = value.Id; } } } } diff --git a/src/Discord.Net/API/Rest/DeleteMessagesParam.cs b/src/Discord.Net/API/Rest/DeleteMessagesParams.cs similarity index 53% rename from src/Discord.Net/API/Rest/DeleteMessagesParam.cs rename to src/Discord.Net/API/Rest/DeleteMessagesParams.cs index 8a794499c..1ea2fca2a 100644 --- a/src/Discord.Net/API/Rest/DeleteMessagesParam.cs +++ b/src/Discord.Net/API/Rest/DeleteMessagesParams.cs @@ -1,11 +1,14 @@ using Newtonsoft.Json; using System.Collections.Generic; +using System.Linq; namespace Discord.API.Rest { - public class DeleteMessagesParam + public class DeleteMessagesParams { [JsonProperty("messages")] public IEnumerable MessageIds { get; set; } + [JsonIgnore] + public IEnumerable Messages { set { MessageIds = value.Select(x => x.Id); } } } } diff --git a/src/Discord.Net/API/Rest/GetChannelMessagesParams.cs b/src/Discord.Net/API/Rest/GetChannelMessagesParams.cs index c14d1c65f..18107807e 100644 --- a/src/Discord.Net/API/Rest/GetChannelMessagesParams.cs +++ b/src/Discord.Net/API/Rest/GetChannelMessagesParams.cs @@ -6,5 +6,6 @@ public Direction RelativeDirection { get; set; } = Direction.Before; public Optional RelativeMessageId { get; set; } + public Optional RelativeMessage { set { RelativeMessageId = value.IsSpecified ? value.Value.Id : Optional.Create(); } } } } diff --git a/src/Discord.Net/API/Rest/LoginParams.cs b/src/Discord.Net/API/Rest/LoginParams.cs deleted file mode 100644 index c5d028063..000000000 --- a/src/Discord.Net/API/Rest/LoginParams.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Newtonsoft.Json; - -namespace Discord.API.Rest -{ - public class LoginParams - { - [JsonProperty("email")] - public string Email { get; set; } - [JsonProperty("password")] - public string Password { get; set; } - } -} diff --git a/src/Discord.Net/API/Rest/LoginResponse.cs b/src/Discord.Net/API/Rest/LoginResponse.cs deleted file mode 100644 index 2d566612d..000000000 --- a/src/Discord.Net/API/Rest/LoginResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Newtonsoft.Json; - -namespace Discord.API.Rest -{ - public class LoginResponse - { - [JsonProperty("token")] - public string Token { get; set; } - } -} diff --git a/src/Discord.Net/API/Rest/ModifyCurrentUserParams.cs b/src/Discord.Net/API/Rest/ModifyCurrentUserParams.cs index a29a9c8b4..7e9b668ef 100644 --- a/src/Discord.Net/API/Rest/ModifyCurrentUserParams.cs +++ b/src/Discord.Net/API/Rest/ModifyCurrentUserParams.cs @@ -1,5 +1,4 @@ -using Discord.Net.Converters; -using Newtonsoft.Json; +using Newtonsoft.Json; using System.IO; namespace Discord.API.Rest @@ -8,12 +7,6 @@ namespace Discord.API.Rest { [JsonProperty("username")] public Optional Username { get; set; } - [JsonProperty("email")] - public Optional Email { get; set; } - [JsonProperty("password")] - public Optional Password { get; set; } - [JsonProperty("new_password")] - public Optional NewPassword { get; set; } [JsonProperty("avatar"), Image] public Optional Avatar { get; set; } } diff --git a/src/Discord.Net/API/Rest/ModifyGuildEmbedParams.cs b/src/Discord.Net/API/Rest/ModifyGuildEmbedParams.cs index f717b4d52..f8e8de1f1 100644 --- a/src/Discord.Net/API/Rest/ModifyGuildEmbedParams.cs +++ b/src/Discord.Net/API/Rest/ModifyGuildEmbedParams.cs @@ -1,5 +1,4 @@ -using Discord.Net.Converters; -using Newtonsoft.Json; +using Newtonsoft.Json; namespace Discord.API.Rest { @@ -7,7 +6,10 @@ namespace Discord.API.Rest { [JsonProperty("enabled")] public Optional Enabled { get; set; } + [JsonProperty("channel")] - public Optional Channel { get; set; } + public Optional ChannelId { get; set; } + [JsonIgnore] + public Optional Channel { set { ChannelId = value.IsSpecified ? value.Value.Id : Optional.Create(); } } } } diff --git a/src/Discord.Net/API/Rest/ModifyGuildMemberParams.cs b/src/Discord.Net/API/Rest/ModifyGuildMemberParams.cs index 0fbaa6d15..8a4077e90 100644 --- a/src/Discord.Net/API/Rest/ModifyGuildMemberParams.cs +++ b/src/Discord.Net/API/Rest/ModifyGuildMemberParams.cs @@ -1,18 +1,26 @@ using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; namespace Discord.API.Rest { public class ModifyGuildMemberParams { - [JsonProperty("roles")] - public Optional Roles { get; set; } [JsonProperty("mute")] public Optional Mute { get; set; } [JsonProperty("deaf")] public Optional Deaf { get; set; } [JsonProperty("nick")] public Optional Nickname { get; set; } + + [JsonProperty("roles")] + public Optional> RoleIds { get; set; } + [JsonIgnore] + public Optional> Roles { set { RoleIds = value.IsSpecified ? Optional.Create(value.Value.Select(x => x.Id)) : Optional.Create>(); } } + [JsonProperty("channel_id")] - public Optional VoiceChannel { get; set; } + public Optional VoiceChannelId { get; set; } + [JsonIgnore] + public Optional VoiceChannel { set { VoiceChannelId = value.IsSpecified ? value.Value.Id : Optional.Create(); } } } } diff --git a/src/Discord.Net/API/Rest/ModifyGuildParams.cs b/src/Discord.Net/API/Rest/ModifyGuildParams.cs index 6e7ff2e34..bbdd46d28 100644 --- a/src/Discord.Net/API/Rest/ModifyGuildParams.cs +++ b/src/Discord.Net/API/Rest/ModifyGuildParams.cs @@ -1,5 +1,4 @@ -using Discord.Net.Converters; -using Newtonsoft.Json; +using Newtonsoft.Json; using System.IO; namespace Discord.API.Rest @@ -11,16 +10,24 @@ namespace Discord.API.Rest [JsonProperty("region")] public Optional Region { get; set; } [JsonProperty("verification_level")] - public Optional VerificationLevel { get; set; } - [JsonProperty("afk_channel_id")] - public Optional AFKChannelId { get; set; } + public Optional VerificationLevel { get; set; } + [JsonProperty("default_message_notifications")] + public Optional DefaultMessageNotifications { get; set; } [JsonProperty("afk_timeout")] public Optional AFKTimeout { get; set; } [JsonProperty("icon"), Image] public Optional Icon { get; set; } - [JsonProperty("owner_id")] - public Optional Owner { get; set; } [JsonProperty("splash"), Image] public Optional Splash { get; set; } + + [JsonProperty("afk_channel_id")] + public Optional AFKChannelId { get; set; } + [JsonIgnore] + public Optional AFKChannel { set { OwnerId = value.IsSpecified ? value.Value.Id : Optional.Create(); } } + + [JsonProperty("owner_id")] + public Optional OwnerId { get; set; } + [JsonIgnore] + public Optional Owner { set { OwnerId = value.IsSpecified ? value.Value.Id : Optional.Create(); } } } } diff --git a/src/Discord.Net/API/Rest/ModifyPresenceParams.cs b/src/Discord.Net/API/Rest/ModifyPresenceParams.cs new file mode 100644 index 000000000..6c6579e4d --- /dev/null +++ b/src/Discord.Net/API/Rest/ModifyPresenceParams.cs @@ -0,0 +1,8 @@ +namespace Discord.API.Rest +{ + public class ModifyPresenceParams + { + public Optional Status { get; set; } + public Optional Game { get; set; } + } +} diff --git a/src/Discord.Net/API/Voice/IdentifyParams.cs b/src/Discord.Net/API/Voice/IdentifyParams.cs new file mode 100644 index 000000000..39889bfad --- /dev/null +++ b/src/Discord.Net/API/Voice/IdentifyParams.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + public class IdentifyParams + { + [JsonProperty("server_id")] + public ulong GuildId { get; set; } + [JsonProperty("user_id")] + public ulong UserId { get; set; } + [JsonProperty("session_id")] + public string SessionId { get; set; } + [JsonProperty("token")] + public string Token { get; set; } + } +} diff --git a/src/Discord.Net/API/Voice/ReadyEvent.cs b/src/Discord.Net/API/Voice/ReadyEvent.cs new file mode 100644 index 000000000..7914fd825 --- /dev/null +++ b/src/Discord.Net/API/Voice/ReadyEvent.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + public class ReadyEvent + { + [JsonProperty("ssrc")] + public uint SSRC { get; set; } + [JsonProperty("port")] + public ushort Port { get; set; } + [JsonProperty("modes")] + public string[] Modes { get; set; } + [JsonProperty("heartbeat_interval")] + public int HeartbeatInterval { get; set; } + } +} diff --git a/src/Discord.Net/API/Voice/SelectProtocolParams.cs b/src/Discord.Net/API/Voice/SelectProtocolParams.cs new file mode 100644 index 000000000..b7992ba5a --- /dev/null +++ b/src/Discord.Net/API/Voice/SelectProtocolParams.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + public class SelectProtocolParams + { + [JsonProperty("protocol")] + public string Protocol { get; set; } + [JsonProperty("data")] + public UdpProtocolInfo Data { get; set; } + } +} diff --git a/src/Discord.Net/API/Voice/SessionDescriptionEvent.cs b/src/Discord.Net/API/Voice/SessionDescriptionEvent.cs new file mode 100644 index 000000000..6a2846f8f --- /dev/null +++ b/src/Discord.Net/API/Voice/SessionDescriptionEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + public class SessionDescriptionEvent + { + [JsonProperty("secret_key")] + public byte[] SecretKey { get; set; } + [JsonProperty("mode")] + public string Mode { get; set; } + } +} diff --git a/src/Discord.Net/API/Voice/SpeakingParams.cs b/src/Discord.Net/API/Voice/SpeakingParams.cs new file mode 100644 index 000000000..01fd001b0 --- /dev/null +++ b/src/Discord.Net/API/Voice/SpeakingParams.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + public class SpeakingParams + { + [JsonProperty("speaking")] + public bool IsSpeaking { get; set; } + [JsonProperty("delay")] + public int Delay { get; set; } + } +} diff --git a/src/Discord.Net/API/Voice/UdpProtocolInfo.cs b/src/Discord.Net/API/Voice/UdpProtocolInfo.cs new file mode 100644 index 000000000..d31bbd8db --- /dev/null +++ b/src/Discord.Net/API/Voice/UdpProtocolInfo.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + public class UdpProtocolInfo + { + [JsonProperty("address")] + public string Address { get; set; } + [JsonProperty("port")] + public int Port { get; set; } + [JsonProperty("mode")] + public string Mode { get; set; } + } +} diff --git a/src/Discord.Net/API/Voice/VoiceOpCodes.cs b/src/Discord.Net/API/Voice/VoiceOpCode.cs similarity index 74% rename from src/Discord.Net/API/Voice/VoiceOpCodes.cs rename to src/Discord.Net/API/Voice/VoiceOpCode.cs index 73c7eda0c..b571f2d96 100644 --- a/src/Discord.Net/API/Voice/VoiceOpCodes.cs +++ b/src/Discord.Net/API/Voice/VoiceOpCode.cs @@ -1,6 +1,6 @@ -namespace Discord.API.Gateway +namespace Discord.API.Voice { - public enum VoiceOpCodes : byte + public enum VoiceOpCode : byte { /// C→S - Used to associate a connection with a token. Identify = 0, @@ -8,8 +8,10 @@ SelectProtocol = 1, /// C←S - Used to notify that the voice connection was successful and informs the client of available protocols. Ready = 2, - /// C↔S - Used to keep the connection alive and measure latency. + /// C→S - Used to keep the connection alive and measure latency. Heartbeat = 3, + /// C←S - Used to reply to a client's heartbeat. + HeartbeatAck = 3, /// C←S - Used to provide an encryption key to the client. SessionDescription = 4, /// C↔S - Used to inform that a certain user is speaking. diff --git a/src/Discord.Net/API/WebSocketMessage.cs b/src/Discord.Net/API/WebSocketMessage.cs index 19ec2ac41..3c7d11b70 100644 --- a/src/Discord.Net/API/WebSocketMessage.cs +++ b/src/Discord.Net/API/WebSocketMessage.cs @@ -9,7 +9,7 @@ namespace Discord.API [JsonProperty("t", NullValueHandling = NullValueHandling.Ignore)] public string Type { get; set; } [JsonProperty("s", NullValueHandling = NullValueHandling.Ignore)] - public uint? Sequence { get; set; } + public int? Sequence { get; set; } [JsonProperty("d")] public object Payload { get; set; } } diff --git a/src/Discord.Net/Audio/AudioClient.cs b/src/Discord.Net/Audio/AudioClient.cs new file mode 100644 index 000000000..cc357a910 --- /dev/null +++ b/src/Discord.Net/Audio/AudioClient.cs @@ -0,0 +1,330 @@ +using Discord.API.Voice; +using Discord.Logging; +using Discord.Net.Converters; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + internal class AudioClient : IAudioClient, IDisposable + { + public const int SampleRate = 48000; + + public event Func Connected + { + add { _connectedEvent.Add(value); } + remove { _connectedEvent.Remove(value); } + } + private readonly AsyncEvent> _connectedEvent = new AsyncEvent>(); + public event Func Disconnected + { + add { _disconnectedEvent.Add(value); } + remove { _disconnectedEvent.Remove(value); } + } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + public event Func LatencyUpdated + { + add { _latencyUpdatedEvent.Add(value); } + remove { _latencyUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _latencyUpdatedEvent = new AsyncEvent>(); + + private readonly ILogger _audioLogger; +#if BENCHMARK + private readonly ILogger _benchmarkLogger; +#endif + internal readonly SemaphoreSlim _connectionLock; + private readonly JsonSerializer _serializer; + + private TaskCompletionSource _connectTask; + private CancellationTokenSource _cancelToken; + private Task _heartbeatTask; + private long _heartbeatTime; + private string _url; + private bool _isDisposed; + private uint _ssrc; + private byte[] _secretKey; + + public CachedGuild Guild { get; } + public DiscordVoiceAPIClient ApiClient { get; private set; } + public ConnectionState ConnectionState { get; private set; } + public int Latency { get; private set; } + + private DiscordSocketClient Discord => Guild.Discord; + + /// Creates a new REST/WebSocket discord client. + public AudioClient(CachedGuild guild, int id) + { + Guild = guild; + + _audioLogger = Discord.LogManager.CreateLogger($"Audio #{id}"); +#if BENCHMARK + _benchmarkLogger = logManager.CreateLogger("Benchmark"); +#endif + + _connectionLock = new SemaphoreSlim(1, 1); + + _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + _serializer.Error += (s, e) => + { + _audioLogger.WarningAsync(e.ErrorContext.Error).GetAwaiter().GetResult(); + e.ErrorContext.Handled = true; + }; + + ApiClient = new DiscordVoiceAPIClient(guild.Id, Discord.WebSocketProvider); + + ApiClient.SentGatewayMessage += async opCode => await _audioLogger.DebugAsync($"Sent {opCode}").ConfigureAwait(false); + ApiClient.SentDiscovery += async () => await _audioLogger.DebugAsync($"Sent Discovery").ConfigureAwait(false); + ApiClient.ReceivedEvent += ProcessMessageAsync; + ApiClient.ReceivedPacket += ProcessPacketAsync; + ApiClient.Disconnected += async ex => + { + if (ex != null) + await _audioLogger.WarningAsync($"Connection Closed: {ex.Message}").ConfigureAwait(false); + else + await _audioLogger.WarningAsync($"Connection Closed").ConfigureAwait(false); + }; + } + + /// + public async Task ConnectAsync(string url, ulong userId, string sessionId, string token) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync(url, userId, sessionId, token).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task ConnectInternalAsync(string url, ulong userId, string sessionId, string token) + { + var state = ConnectionState; + if (state == ConnectionState.Connecting || state == ConnectionState.Connected) + await DisconnectInternalAsync(null).ConfigureAwait(false); + + ConnectionState = ConnectionState.Connecting; + await _audioLogger.InfoAsync("Connecting").ConfigureAwait(false); + try + { + _url = url; + _connectTask = new TaskCompletionSource(); + _cancelToken = new CancellationTokenSource(); + + await ApiClient.ConnectAsync("wss://" + url).ConfigureAwait(false); + await ApiClient.SendIdentityAsync(userId, sessionId, token).ConfigureAwait(false); + await _connectTask.Task.ConfigureAwait(false); + + await _connectedEvent.InvokeAsync().ConfigureAwait(false); + ConnectionState = ConnectionState.Connected; + await _audioLogger.InfoAsync("Connected").ConfigureAwait(false); + } + catch (Exception) + { + await DisconnectInternalAsync(null).ConfigureAwait(false); + throw; + } + } + /// + public async Task DisconnectAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(null).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task DisconnectAsync(Exception ex) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(ex).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task DisconnectInternalAsync(Exception ex) + { + if (ConnectionState == ConnectionState.Disconnected) return; + ConnectionState = ConnectionState.Disconnecting; + await _audioLogger.InfoAsync("Disconnecting").ConfigureAwait(false); + + //Signal tasks to complete + try { _cancelToken.Cancel(); } catch { } + + //Disconnect from server + await ApiClient.DisconnectAsync().ConfigureAwait(false); + + //Wait for tasks to complete + var heartbeatTask = _heartbeatTask; + if (heartbeatTask != null) + await heartbeatTask.ConfigureAwait(false); + _heartbeatTask = null; + + ConnectionState = ConnectionState.Disconnected; + await _audioLogger.InfoAsync("Disconnected").ConfigureAwait(false); + + await _disconnectedEvent.InvokeAsync(ex).ConfigureAwait(false); + } + + public void Send(byte[] data, int count) + { + //TODO: Queue these? + ApiClient.SendAsync(data, count).ConfigureAwait(false); + } + + public RTPWriteStream CreateOpusStream(int samplesPerFrame, int bufferSize = 4000) + { + return new RTPWriteStream(this, _secretKey, samplesPerFrame, _ssrc, bufferSize = 4000); + } + public OpusEncodeStream CreatePCMStream(int samplesPerFrame, int? bitrate = null, int channels = 2, + OpusApplication application = OpusApplication.MusicOrMixed, int bufferSize = 4000) + { + return new OpusEncodeStream(this, _secretKey, samplesPerFrame, _ssrc, SampleRate, bitrate, channels, application, bufferSize); + } + + private async Task ProcessMessageAsync(VoiceOpCode opCode, object payload) + { +#if BENCHMARK + Stopwatch stopwatch = Stopwatch.StartNew(); + try + { +#endif + try + { + switch (opCode) + { + case VoiceOpCode.Ready: + { + await _audioLogger.DebugAsync("Received Ready").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + _ssrc = data.SSRC; + + if (!data.Modes.Contains(DiscordVoiceAPIClient.Mode)) + throw new InvalidOperationException($"Discord does not support {DiscordVoiceAPIClient.Mode}"); + + _heartbeatTime = 0; + _heartbeatTask = RunHeartbeatAsync(data.HeartbeatInterval, _cancelToken.Token); + + var entry = await Dns.GetHostEntryAsync(_url).ConfigureAwait(false); + + ApiClient.SetUdpEndpoint(new IPEndPoint(entry.AddressList[0], data.Port)); + await ApiClient.SendDiscoveryAsync(_ssrc).ConfigureAwait(false); + } + break; + case VoiceOpCode.SessionDescription: + { + await _audioLogger.DebugAsync("Received SessionDescription").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + if (data.Mode != DiscordVoiceAPIClient.Mode) + throw new InvalidOperationException($"Discord selected an unexpected mode: {data.Mode}"); + + _secretKey = data.SecretKey; + await ApiClient.SendSetSpeaking(true).ConfigureAwait(false); + + _connectTask.TrySetResult(true); + } + break; + case VoiceOpCode.HeartbeatAck: + { + await _audioLogger.DebugAsync("Received HeartbeatAck").ConfigureAwait(false); + + var heartbeatTime = _heartbeatTime; + if (heartbeatTime != 0) + { + int latency = (int)(Environment.TickCount - _heartbeatTime); + _heartbeatTime = 0; + await _audioLogger.VerboseAsync($"Latency = {latency} ms").ConfigureAwait(false); + + int before = Latency; + Latency = latency; + + await _latencyUpdatedEvent.InvokeAsync(before, latency).ConfigureAwait(false); + } + } + break; + default: + await _audioLogger.WarningAsync($"Unknown OpCode ({opCode})").ConfigureAwait(false); + return; + } + } + catch (Exception ex) + { + await _audioLogger.ErrorAsync($"Error handling {opCode}", ex).ConfigureAwait(false); + return; + } +#if BENCHMARK + } + finally + { + stopwatch.Stop(); + double millis = Math.Round(stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2); + await _benchmarkLogger.DebugAsync($"{millis} ms").ConfigureAwait(false); + } +#endif + } + private async Task ProcessPacketAsync(byte[] packet) + { + if (!_connectTask.Task.IsCompleted) + { + if (packet.Length == 70) + { + string ip; + int port; + try + { + ip = Encoding.UTF8.GetString(packet, 4, 70 - 6).TrimEnd('\0'); + port = packet[68] | packet[69] << 8; + } + catch { return; } + + await _audioLogger.DebugAsync("Received Discovery").ConfigureAwait(false); + await ApiClient.SendSelectProtocol(ip, port); + } + } + } + + private async Task RunHeartbeatAsync(int intervalMillis, CancellationToken cancelToken) + { + //Clean this up when Discord's session patch is live + try + { + while (!cancelToken.IsCancellationRequested) + { + await Task.Delay(intervalMillis, cancelToken).ConfigureAwait(false); + + if (_heartbeatTime != 0) //Server never responded to our last heartbeat + { + if (ConnectionState == ConnectionState.Connected) + { + await _audioLogger.WarningAsync("Server missed last heartbeat").ConfigureAwait(false); + await DisconnectInternalAsync(new Exception("Server missed last heartbeat")).ConfigureAwait(false); + return; + } + } + else + _heartbeatTime = Environment.TickCount; + await ApiClient.SendHeartbeatAsync().ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + } + + internal virtual void Dispose(bool disposing) + { + if (!_isDisposed) + _isDisposed = true; + ApiClient.Dispose(); + } + /// + public void Dispose() => Dispose(true); + } +} diff --git a/src/Discord.Net/Audio/AudioMode.cs b/src/Discord.Net/Audio/AudioMode.cs new file mode 100644 index 000000000..7cc5a08c1 --- /dev/null +++ b/src/Discord.Net/Audio/AudioMode.cs @@ -0,0 +1,13 @@ +using System; + +namespace Discord.Audio +{ + [Flags] + public enum AudioMode : byte + { + Disabled = 0, + Outgoing = 1, + Incoming = 2, + Both = Outgoing | Incoming + } +} diff --git a/src/Discord.Net/Audio/IAudioClient.cs b/src/Discord.Net/Audio/IAudioClient.cs new file mode 100644 index 000000000..b225312dc --- /dev/null +++ b/src/Discord.Net/Audio/IAudioClient.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + public interface IAudioClient + { + event Func Connected; + event Func Disconnected; + event Func LatencyUpdated; + + DiscordVoiceAPIClient ApiClient { get; } + /// Gets the current connection state of this client. + ConnectionState ConnectionState { get; } + /// Gets the estimated round-trip latency, in milliseconds, to the gateway server. + int Latency { get; } + + Task DisconnectAsync(); + + RTPWriteStream CreateOpusStream(int samplesPerFrame, int bufferSize = 4000); + OpusEncodeStream CreatePCMStream(int samplesPerFrame, int? bitrate = null, int channels = 2, + OpusApplication application = OpusApplication.MusicOrMixed, int bufferSize = 4000); + } +} diff --git a/src/Discord.Net/Audio/Opus/OpusApplication.cs b/src/Discord.Net/Audio/Opus/OpusApplication.cs new file mode 100644 index 000000000..d6a3ce0cf --- /dev/null +++ b/src/Discord.Net/Audio/Opus/OpusApplication.cs @@ -0,0 +1,9 @@ +namespace Discord.Audio +{ + public enum OpusApplication : int + { + Voice = 2048, + MusicOrMixed = 2049, + LowLatency = 2051 + } +} diff --git a/src/Discord.Net/Audio/Opus/OpusConverter.cs b/src/Discord.Net/Audio/Opus/OpusConverter.cs new file mode 100644 index 000000000..732006990 --- /dev/null +++ b/src/Discord.Net/Audio/Opus/OpusConverter.cs @@ -0,0 +1,51 @@ +using System; + +namespace Discord.Audio +{ + internal abstract class OpusConverter : IDisposable + { + protected IntPtr _ptr; + + /// Gets the bit rate of this converter. + public const int BitsPerSample = 16; + /// Gets the bytes per sample. + public const int SampleSize = (BitsPerSample / 8) * MaxChannels; + /// Gets the maximum amount of channels this encoder supports. + public const int MaxChannels = 2; + + /// Gets the input sampling rate of this converter. + public int SamplingRate { get; } + /// Gets the number of samples per second for this stream. + public int Channels { get; } + + protected OpusConverter(int samplingRate, int channels) + { + if (samplingRate != 8000 && samplingRate != 12000 && + samplingRate != 16000 && samplingRate != 24000 && + samplingRate != 48000) + throw new ArgumentOutOfRangeException(nameof(samplingRate)); + if (channels != 1 && channels != 2) + throw new ArgumentOutOfRangeException(nameof(channels)); + + SamplingRate = samplingRate; + Channels = channels; + } + + private bool disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + disposedValue = true; + } + ~OpusConverter() + { + Dispose(false); + } + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Discord.Net/Audio/Opus/OpusCtl.cs b/src/Discord.Net/Audio/Opus/OpusCtl.cs new file mode 100644 index 000000000..e71213ae6 --- /dev/null +++ b/src/Discord.Net/Audio/Opus/OpusCtl.cs @@ -0,0 +1,10 @@ +namespace Discord.Audio +{ + internal enum OpusCtl : int + { + SetBitrateRequest = 4002, + GetBitrateRequest = 4003, + SetInbandFECRequest = 4012, + GetInbandFECRequest = 4013 + } +} diff --git a/src/Discord.Net/Audio/Opus/OpusDecoder.cs b/src/Discord.Net/Audio/Opus/OpusDecoder.cs new file mode 100644 index 000000000..e3a3fa649 --- /dev/null +++ b/src/Discord.Net/Audio/Opus/OpusDecoder.cs @@ -0,0 +1,49 @@ +using System; +using System.Runtime.InteropServices; + +namespace Discord.Audio +{ + internal unsafe class OpusDecoder : OpusConverter + { + [DllImport("opus", EntryPoint = "opus_decoder_create", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr CreateDecoder(int Fs, int channels, out OpusError error); + [DllImport("opus", EntryPoint = "opus_decoder_destroy", CallingConvention = CallingConvention.Cdecl)] + private static extern void DestroyDecoder(IntPtr decoder); + [DllImport("opus", EntryPoint = "opus_decode", CallingConvention = CallingConvention.Cdecl)] + private static extern int Decode(IntPtr st, byte* data, int len, byte* pcm, int max_frame_size, int decode_fec); + + public OpusDecoder(int samplingRate, int channels) + : base(samplingRate, channels) + { + OpusError error; + _ptr = CreateDecoder(samplingRate, channels, out error); + if (error != OpusError.OK) + throw new InvalidOperationException($"Error occured while creating decoder: {error}"); + } + + /// Produces PCM samples from Opus-encoded audio. + /// PCM samples to decode. + /// Offset of the frame in input. + /// Buffer to store the decoded frame. + public unsafe int DecodeFrame(byte[] input, int inputOffset, int inputCount, byte[] output, int outputOffset) + { + int result = 0; + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + result = Decode(_ptr, inPtr + inputOffset, inputCount, outPtr + outputOffset, (output.Length - outputOffset) / SampleSize / MaxChannels, 0); + + if (result < 0) + throw new Exception(((OpusError)result).ToString()); + return result; + } + + protected override void Dispose(bool disposing) + { + if (_ptr != IntPtr.Zero) + { + DestroyDecoder(_ptr); + _ptr = IntPtr.Zero; + } + } + } +} diff --git a/src/Discord.Net/Audio/Opus/OpusEncoder.cs b/src/Discord.Net/Audio/Opus/OpusEncoder.cs new file mode 100644 index 000000000..145447194 --- /dev/null +++ b/src/Discord.Net/Audio/Opus/OpusEncoder.cs @@ -0,0 +1,77 @@ +using System; +using System.Runtime.InteropServices; + +namespace Discord.Audio +{ + internal unsafe class OpusEncoder : OpusConverter + { + [DllImport("opus", EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error); + [DllImport("opus", EntryPoint = "opus_encoder_destroy", CallingConvention = CallingConvention.Cdecl)] + private static extern void DestroyEncoder(IntPtr encoder); + [DllImport("opus", EntryPoint = "opus_encode", CallingConvention = CallingConvention.Cdecl)] + private static extern int Encode(IntPtr st, byte* pcm, int frame_size, byte* data, int max_data_bytes); + [DllImport("opus", EntryPoint = "opus_encoder_ctl", CallingConvention = CallingConvention.Cdecl)] + private static extern int EncoderCtl(IntPtr st, OpusCtl request, int value); + + /// Gets the coding mode of the encoder. + public OpusApplication Application { get; } + + public OpusEncoder(int samplingRate, int channels, OpusApplication application = OpusApplication.MusicOrMixed) + : base(samplingRate, channels) + { + Application = application; + + OpusError error; + _ptr = CreateEncoder(samplingRate, channels, (int)application, out error); + if (error != OpusError.OK) + throw new InvalidOperationException($"Error occured while creating encoder: {error}"); + } + + + /// Produces Opus encoded audio from PCM samples. + /// PCM samples to encode. + /// Offset of the frame in pcmSamples. + /// Buffer to store the encoded frame. + /// Length of the frame contained in outputBuffer. + public unsafe int EncodeFrame(byte[] input, int inputOffset, int inputCount, byte[] output, int outputOffset) + { + int result = 0; + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + result = Encode(_ptr, inPtr + inputOffset, inputCount / SampleSize, outPtr + outputOffset, output.Length - outputOffset); + + if (result < 0) + throw new Exception(((OpusError)result).ToString()); + return result; + } + + /// Gets or sets whether Forward Error Correction is enabled. + public void SetForwardErrorCorrection(bool value) + { + var result = EncoderCtl(_ptr, OpusCtl.SetInbandFECRequest, value ? 1 : 0); + if (result < 0) + throw new Exception(((OpusError)result).ToString()); + } + + /// Gets or sets whether Forward Error Correction is enabled. + public void SetBitrate(int value) + { + if (value < 1 || value > DiscordVoiceAPIClient.MaxBitrate) + throw new ArgumentOutOfRangeException(nameof(value)); + + var result = EncoderCtl(_ptr, OpusCtl.SetBitrateRequest, value * 1000); + if (result < 0) + throw new Exception(((OpusError)result).ToString()); + } + + protected override void Dispose(bool disposing) + { + if (_ptr != IntPtr.Zero) + { + DestroyEncoder(_ptr); + _ptr = IntPtr.Zero; + } + } + } +} diff --git a/src/Discord.Net/Audio/Opus/OpusError.cs b/src/Discord.Net/Audio/Opus/OpusError.cs new file mode 100644 index 000000000..d29d8b9dd --- /dev/null +++ b/src/Discord.Net/Audio/Opus/OpusError.cs @@ -0,0 +1,14 @@ +namespace Discord.Audio +{ + internal enum OpusError : int + { + OK = 0, + BadArg = -1, + BufferToSmall = -2, + InternalError = -3, + InvalidPacket = -4, + Unimplemented = -5, + InvalidState = -6, + AllocFail = -7 + } +} diff --git a/src/Discord.Net/Audio/Sodium/SecretBox.cs b/src/Discord.Net/Audio/Sodium/SecretBox.cs new file mode 100644 index 000000000..ba4bc2e62 --- /dev/null +++ b/src/Discord.Net/Audio/Sodium/SecretBox.cs @@ -0,0 +1,25 @@ +using System.Runtime.InteropServices; + +namespace Discord.Audio +{ + public unsafe static class SecretBox + { + [DllImport("libsodium", EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)] + private static extern int SecretBoxEasy(byte* output, byte* input, long inputLength, byte[] nonce, byte[] secret); + [DllImport("libsodium", EntryPoint = "crypto_secretbox_open_easy", CallingConvention = CallingConvention.Cdecl)] + private static extern int SecretBoxOpenEasy(byte* output, byte* input, long inputLength, byte[] nonce, byte[] secret); + + public static int Encrypt(byte[] input, int inputOffset, long inputLength, byte[] output, int outputOffset, byte[] nonce, byte[] secret) + { + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + return SecretBoxEasy(outPtr + outputOffset, inPtr + inputOffset, inputLength, nonce, secret); + } + public static int Decrypt(byte[] input, int inputOffset, long inputLength, byte[] output, int outputOffset, byte[] nonce, byte[] secret) + { + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + return SecretBoxOpenEasy(outPtr + outputOffset, inPtr + inputOffset, inputLength, nonce, secret); + } + } +} diff --git a/src/Discord.Net/Audio/Streams/OpusDecodeStream.cs b/src/Discord.Net/Audio/Streams/OpusDecodeStream.cs new file mode 100644 index 000000000..c059955a8 --- /dev/null +++ b/src/Discord.Net/Audio/Streams/OpusDecodeStream.cs @@ -0,0 +1,30 @@ +namespace Discord.Audio +{ + public class OpusDecodeStream : RTPReadStream + { + private readonly byte[] _buffer; + private readonly OpusDecoder _decoder; + + internal OpusDecodeStream(AudioClient audioClient, byte[] secretKey, int samplingRate, + int channels = OpusConverter.MaxChannels, int bufferSize = 4000) + : base(audioClient, secretKey) + { + _buffer = new byte[bufferSize]; + _decoder = new OpusDecoder(samplingRate, channels); + } + + public override int Read(byte[] buffer, int offset, int count) + { + count = _decoder.DecodeFrame(buffer, offset, count, _buffer, 0); + return base.Read(_buffer, 0, count); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + _decoder.Dispose(); + } + } +} diff --git a/src/Discord.Net/Audio/Streams/OpusEncodeStream.cs b/src/Discord.Net/Audio/Streams/OpusEncodeStream.cs new file mode 100644 index 000000000..deb44f619 --- /dev/null +++ b/src/Discord.Net/Audio/Streams/OpusEncodeStream.cs @@ -0,0 +1,34 @@ +namespace Discord.Audio +{ + public class OpusEncodeStream : RTPWriteStream + { + private readonly byte[] _buffer; + private readonly OpusEncoder _encoder; + + internal OpusEncodeStream(AudioClient audioClient, byte[] secretKey, int samplesPerFrame, uint ssrc, int samplingRate, int? bitrate = null, + int channels = OpusConverter.MaxChannels, OpusApplication application = OpusApplication.MusicOrMixed, int bufferSize = 4000) + : base(audioClient, secretKey, samplesPerFrame, ssrc) + { + _buffer = new byte[bufferSize]; + _encoder = new OpusEncoder(samplingRate, channels); + + _encoder.SetForwardErrorCorrection(true); + if (bitrate != null) + _encoder.SetBitrate(bitrate.Value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + count = _encoder.EncodeFrame(buffer, offset, count, _buffer, 0); + base.Write(_buffer, 0, count); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + _encoder.Dispose(); + } + } +} diff --git a/src/Discord.Net/Audio/Streams/RTPReadStream.cs b/src/Discord.Net/Audio/Streams/RTPReadStream.cs new file mode 100644 index 000000000..4bf7f5e1b --- /dev/null +++ b/src/Discord.Net/Audio/Streams/RTPReadStream.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Concurrent; +using System.IO; + +namespace Discord.Audio +{ + public class RTPReadStream : Stream + { + private readonly BlockingCollection _queuedData; //TODO: Replace with max-length ring buffer + private readonly AudioClient _audioClient; + private readonly byte[] _buffer, _nonce, _secretKey; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + + internal RTPReadStream(AudioClient audioClient, byte[] secretKey, int bufferSize = 4000) + { + _audioClient = audioClient; + _secretKey = secretKey; + _buffer = new byte[bufferSize]; + _queuedData = new BlockingCollection(100); + _nonce = new byte[24]; + } + + public override int Read(byte[] buffer, int offset, int count) + { + var queuedData = _queuedData.Take(); + Buffer.BlockCopy(queuedData, 0, buffer, offset, Math.Min(queuedData.Length, count)); + return queuedData.Length; + } + public override void Write(byte[] buffer, int offset, int count) + { + Buffer.BlockCopy(buffer, 0, _nonce, 0, 12); + count = SecretBox.Decrypt(buffer, offset, count, _buffer, 0, _nonce, _secretKey); + var newBuffer = new byte[count]; + Buffer.BlockCopy(_buffer, 0, newBuffer, 0, count); + _queuedData.Add(newBuffer); + } + + public override void Flush() { throw new NotSupportedException(); } + + public override long Length { get { throw new NotSupportedException(); } } + public override long Position + { + get { throw new NotSupportedException(); } + set { throw new NotSupportedException(); } + } + + public override void SetLength(long value) { throw new NotSupportedException(); } + public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } + } +} diff --git a/src/Discord.Net/Audio/Streams/RTPWriteStream.cs b/src/Discord.Net/Audio/Streams/RTPWriteStream.cs new file mode 100644 index 000000000..f871ded0d --- /dev/null +++ b/src/Discord.Net/Audio/Streams/RTPWriteStream.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; + +namespace Discord.Audio +{ + public class RTPWriteStream : Stream + { + private readonly AudioClient _audioClient; + private readonly byte[] _buffer, _nonce, _secretKey; + private int _samplesPerFrame; + private uint _ssrc, _timestamp = 0; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + + + internal RTPWriteStream(AudioClient audioClient, byte[] secretKey, int samplesPerFrame, uint ssrc, int bufferSize = 4000) + { + _audioClient = audioClient; + _secretKey = secretKey; + _samplesPerFrame = samplesPerFrame; + _ssrc = ssrc; + _nonce = new byte[24]; + _buffer = new byte[bufferSize]; + _buffer[0] = 0x80; + _buffer[1] = 0x78; + _buffer[8] = (byte)(_ssrc >> 24); + _buffer[9] = (byte)(_ssrc >> 16); + _buffer[10] = (byte)(_ssrc >> 8); + _buffer[11] = (byte)(_ssrc >> 0); + } + + public override void Write(byte[] buffer, int offset, int count) + { + unchecked + { + if (_buffer[3]++ == byte.MaxValue) + _buffer[4]++; + + _timestamp += (uint)_samplesPerFrame; + _buffer[4] = (byte)(_timestamp >> 24); + _buffer[5] = (byte)(_timestamp >> 16); + _buffer[6] = (byte)(_timestamp >> 8); + _buffer[7] = (byte)(_timestamp >> 0); + } + + Buffer.BlockCopy(buffer, 0, _nonce, 0, 12); + count = SecretBox.Encrypt(buffer, offset, count, _buffer, 12, _nonce, _secretKey); + _audioClient.Send(_buffer, count); + } + + public override void Flush() { } + + public override long Length { get { throw new NotSupportedException(); } } + public override long Position + { + get { throw new NotSupportedException(); } + set { throw new NotSupportedException(); } + } + + public override int Read(byte[] buffer, int offset, int count) { throw new NotSupportedException(); } + public override void SetLength(long value) { throw new NotSupportedException(); } + public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } + } +} diff --git a/src/Discord.Net/Data/DataStore.cs b/src/Discord.Net/Data/DataStore.cs new file mode 100644 index 000000000..59b62e180 --- /dev/null +++ b/src/Discord.Net/Data/DataStore.cs @@ -0,0 +1,116 @@ +using Discord.Extensions; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Discord +{ + public class DataStore + { + private const int CollectionConcurrencyLevel = 1; //WebSocket updater/event handler. //TODO: Needs profiling, increase to 2? + private const double AverageChannelsPerGuild = 10.22; //Source: Googie2149 + private const double AverageUsersPerGuild = 47.78; //Source: Googie2149 + private const double CollectionMultiplier = 1.05; //Add 5% buffer to handle growth + + private readonly ConcurrentDictionary _channels; + private readonly ConcurrentDictionary _dmChannels; + private readonly ConcurrentDictionary _guilds; + private readonly ConcurrentDictionary _users; + + internal IReadOnlyCollection Channels => _channels.ToReadOnlyCollection(); + internal IReadOnlyCollection DMChannels => _dmChannels.ToReadOnlyCollection(); + internal IReadOnlyCollection Guilds => _guilds.ToReadOnlyCollection(); + internal IReadOnlyCollection Users => _users.ToReadOnlyCollection(); + + public DataStore(int guildCount, int dmChannelCount) + { + double estimatedChannelCount = guildCount * AverageChannelsPerGuild + dmChannelCount; + double estimatedUsersCount = guildCount * AverageUsersPerGuild; + _channels = new ConcurrentDictionary(CollectionConcurrencyLevel, (int)(estimatedChannelCount * CollectionMultiplier)); + _dmChannels = new ConcurrentDictionary(CollectionConcurrencyLevel, (int)(dmChannelCount * CollectionMultiplier)); + _guilds = new ConcurrentDictionary(CollectionConcurrencyLevel, (int)(guildCount * CollectionMultiplier)); + _users = new ConcurrentDictionary(CollectionConcurrencyLevel, (int)(estimatedUsersCount * CollectionMultiplier)); + } + + internal ICachedChannel GetChannel(ulong id) + { + ICachedChannel channel; + if (_channels.TryGetValue(id, out channel)) + return channel; + return null; + } + internal void AddChannel(ICachedChannel channel) + { + _channels[channel.Id] = channel; + } + internal ICachedChannel RemoveChannel(ulong id) + { + ICachedChannel channel; + if (_channels.TryRemove(id, out channel)) + return channel; + return null; + } + + internal CachedDMChannel GetDMChannel(ulong userId) + { + CachedDMChannel channel; + if (_dmChannels.TryGetValue(userId, out channel)) + return channel; + return null; + } + internal void AddDMChannel(CachedDMChannel channel) + { + _channels[channel.Id] = channel; + _dmChannels[channel.Recipient.Id] = channel; + } + internal CachedDMChannel RemoveDMChannel(ulong userId) + { + CachedDMChannel channel; + ICachedChannel ignored; + if (_dmChannels.TryRemove(userId, out channel)) + { + if (_channels.TryRemove(channel.Id, out ignored)) + return channel; + } + return null; + } + + internal CachedGuild GetGuild(ulong id) + { + CachedGuild guild; + if (_guilds.TryGetValue(id, out guild)) + return guild; + return null; + } + internal void AddGuild(CachedGuild guild) + { + _guilds[guild.Id] = guild; + } + internal CachedGuild RemoveGuild(ulong id) + { + CachedGuild guild; + if (_guilds.TryRemove(id, out guild)) + return guild; + return null; + } + + internal CachedGlobalUser GetUser(ulong id) + { + CachedGlobalUser user; + if (_users.TryGetValue(id, out user)) + return user; + return null; + } + internal CachedGlobalUser GetOrAddUser(ulong id, Func userFactory) + { + return _users.GetOrAdd(id, userFactory); + } + internal CachedGlobalUser RemoveUser(ulong id) + { + CachedGlobalUser user; + if (_users.TryRemove(id, out user)) + return user; + return null; + } + } +} diff --git a/src/Discord.Net/DateTimeUtils.cs b/src/Discord.Net/DateTimeUtils.cs deleted file mode 100644 index 92a42e74b..000000000 --- a/src/Discord.Net/DateTimeUtils.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace Discord -{ - internal static class DateTimeUtils - { - private const ulong EpochTicks = 621355968000000000UL; - private const ulong DiscordEpochMillis = 1420070400000UL; - - public static DateTime FromEpochMilliseconds(ulong value) - => new DateTime((long)(value * TimeSpan.TicksPerMillisecond + EpochTicks), DateTimeKind.Utc); - public static DateTime FromEpochSeconds(ulong value) - => new DateTime((long)(value * TimeSpan.TicksPerSecond + EpochTicks), DateTimeKind.Utc); - - public static DateTime FromSnowflake(ulong value) - => FromEpochMilliseconds((value >> 22) + DiscordEpochMillis); - } -} diff --git a/src/Discord.Net/DiscordClient.cs b/src/Discord.Net/DiscordClient.cs new file mode 100644 index 000000000..7231db649 --- /dev/null +++ b/src/Discord.Net/DiscordClient.cs @@ -0,0 +1,274 @@ +using Discord.API.Rest; +using Discord.Logging; +using Discord.Net; +using Discord.Net.Queue; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord +{ + public class DiscordClient : IDiscordClient + { + private readonly object _eventLock = new object(); + + public event Func Log { add { _logEvent.Add(value); } remove { _logEvent.Remove(value); } } + private readonly AsyncEvent> _logEvent = new AsyncEvent>(); + + public event Func LoggedIn { add { _loggedInEvent.Add(value); } remove { _loggedInEvent.Remove(value); } } + private readonly AsyncEvent> _loggedInEvent = new AsyncEvent>(); + public event Func LoggedOut { add { _loggedOutEvent.Add(value); } remove { _loggedOutEvent.Remove(value); } } + private readonly AsyncEvent> _loggedOutEvent = new AsyncEvent>(); + + internal readonly ILogger _discordLogger, _restLogger, _queueLogger; + internal readonly SemaphoreSlim _connectionLock; + internal readonly RequestQueue _requestQueue; + internal bool _isDisposed; + internal SelfUser _currentUser; + + public API.DiscordApiClient ApiClient { get; } + internal LogManager LogManager { get; } + public LoginState LoginState { get; private set; } + + /// Creates a new REST-only discord client. + public DiscordClient() : this(new DiscordConfig()) { } + /// Creates a new REST-only discord client. + public DiscordClient(DiscordConfig config) + { + LogManager = new LogManager(config.LogLevel); + LogManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false); + _discordLogger = LogManager.CreateLogger("Discord"); + _restLogger = LogManager.CreateLogger("Rest"); + _queueLogger = LogManager.CreateLogger("Queue"); + + _connectionLock = new SemaphoreSlim(1, 1); + + _requestQueue = new RequestQueue(); + _requestQueue.RateLimitTriggered += async (id, bucket, millis) => + { + await _queueLogger.WarningAsync($"Rate limit triggered (id = \"{id ?? "null"}\")").ConfigureAwait(false); + if (bucket == null && id != null) + await _queueLogger.WarningAsync($"Unknown rate limit bucket \"{id ?? "null"}\"").ConfigureAwait(false); + }; + + var restProvider = config.RestClientProvider; + var webSocketProvider = (this is DiscordSocketClient) ? (config as DiscordSocketConfig)?.WebSocketProvider : null; //TODO: Clean this check + ApiClient = new API.DiscordApiClient(restProvider, webSocketProvider, requestQueue: _requestQueue); + ApiClient.SentRequest += async (method, endpoint, millis) => await _restLogger.VerboseAsync($"{method} {endpoint}: {millis} ms").ConfigureAwait(false); + } + + /// + public async Task LoginAsync(TokenType tokenType, string token, bool validateToken = true) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await LoginInternalAsync(tokenType, token, validateToken).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task LoginInternalAsync(TokenType tokenType, string token, bool validateToken) + { + if (LoginState != LoginState.LoggedOut) + await LogoutInternalAsync().ConfigureAwait(false); + LoginState = LoginState.LoggingIn; + + try + { + await ApiClient.LoginAsync(tokenType, token).ConfigureAwait(false); + + if (validateToken) + { + try + { + await ApiClient.ValidateTokenAsync().ConfigureAwait(false); + } + catch (HttpException ex) + { + throw new ArgumentException("Token validation failed", nameof(token), ex); + } + } + + await OnLoginAsync().ConfigureAwait(false); + + LoginState = LoginState.LoggedIn; + } + catch (Exception) + { + await LogoutInternalAsync().ConfigureAwait(false); + throw; + } + + await _loggedInEvent.InvokeAsync().ConfigureAwait(false); + } + protected virtual Task OnLoginAsync() => Task.CompletedTask; + + /// + public async Task LogoutAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await LogoutInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task LogoutInternalAsync() + { + if (LoginState == LoginState.LoggedOut) return; + LoginState = LoginState.LoggingOut; + + await ApiClient.LogoutAsync().ConfigureAwait(false); + + await OnLogoutAsync().ConfigureAwait(false); + + _currentUser = null; + + LoginState = LoginState.LoggedOut; + + await _loggedOutEvent.InvokeAsync().ConfigureAwait(false); + } + protected virtual Task OnLogoutAsync() => Task.CompletedTask; + + /// + public async Task> GetConnectionsAsync() + { + var models = await ApiClient.GetMyConnectionsAsync().ConfigureAwait(false); + return models.Select(x => new Connection(x)).ToImmutableArray(); + } + + /// + public virtual async Task GetChannelAsync(ulong id) + { + var model = await ApiClient.GetChannelAsync(id).ConfigureAwait(false); + if (model != null) + { + if (model.GuildId.IsSpecified) + { + var guildModel = await ApiClient.GetGuildAsync(model.GuildId.Value).ConfigureAwait(false); + if (guildModel != null) + { + var guild = new Guild(this, guildModel); + return guild.ToChannel(model); + } + } + else + return new DMChannel(this, new User(model.Recipient.Value), model); + } + return null; + } + /// + public virtual async Task> GetDMChannelsAsync() + { + var models = await ApiClient.GetMyDMsAsync().ConfigureAwait(false); + return models.Select(x => new DMChannel(this, new User(x.Recipient.Value), x)).ToImmutableArray(); + } + + /// + public virtual async Task GetInviteAsync(string inviteIdOrXkcd) + { + var model = await ApiClient.GetInviteAsync(inviteIdOrXkcd).ConfigureAwait(false); + if (model != null) + return new Invite(this, model); + return null; + } + + /// + public virtual async Task GetGuildAsync(ulong id) + { + var model = await ApiClient.GetGuildAsync(id).ConfigureAwait(false); + if (model != null) + return new Guild(this, model); + return null; + } + /// + public virtual async Task GetGuildEmbedAsync(ulong id) + { + var model = await ApiClient.GetGuildEmbedAsync(id).ConfigureAwait(false); + if (model != null) + return new GuildEmbed(model); + return null; + } + /// + public virtual async Task> GetGuildsAsync() + { + var models = await ApiClient.GetMyGuildsAsync().ConfigureAwait(false); + return models.Select(x => new UserGuild(this, x)).ToImmutableArray(); + + } + /// + public virtual async Task CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon = null) + { + var args = new CreateGuildParams(); + var model = await ApiClient.CreateGuildAsync(args).ConfigureAwait(false); + return new Guild(this, model); + } + + /// + public virtual async Task GetUserAsync(ulong id) + { + var model = await ApiClient.GetUserAsync(id).ConfigureAwait(false); + if (model != null) + return new User(model); + return null; + } + /// + public virtual async Task GetUserAsync(string username, string discriminator) + { + var model = await ApiClient.GetUserAsync(username, discriminator).ConfigureAwait(false); + if (model != null) + return new User(model); + return null; + } + /// + public virtual async Task GetCurrentUserAsync() + { + var user = _currentUser; + if (user == null) + { + var model = await ApiClient.GetSelfAsync().ConfigureAwait(false); + user = new SelfUser(this, model); + _currentUser = user; + } + return user; + } + /// + public virtual async Task> QueryUsersAsync(string query, int limit) + { + var models = await ApiClient.QueryUsersAsync(query, limit).ConfigureAwait(false); + return models.Select(x => new User(x)).ToImmutableArray(); + } + + /// + public virtual async Task> GetVoiceRegionsAsync() + { + var models = await ApiClient.GetVoiceRegionsAsync().ConfigureAwait(false); + return models.Select(x => new VoiceRegion(x)).ToImmutableArray(); + } + /// + public virtual async Task GetVoiceRegionAsync(string id) + { + var models = await ApiClient.GetVoiceRegionsAsync().ConfigureAwait(false); + return models.Select(x => new VoiceRegion(x)).Where(x => x.Id == id).FirstOrDefault(); + } + + internal virtual void Dispose(bool disposing) + { + if (!_isDisposed) + _isDisposed = true; + ApiClient.Dispose(); + } + /// + public void Dispose() => Dispose(true); + + ConnectionState IDiscordClient.ConnectionState => ConnectionState.Disconnected; + ILogManager IDiscordClient.LogManager => LogManager; + + Task IDiscordClient.ConnectAsync() { throw new NotSupportedException(); } + Task IDiscordClient.DisconnectAsync() { throw new NotSupportedException(); } + } +} diff --git a/src/Discord.Net/DiscordConfig.cs b/src/Discord.Net/DiscordConfig.cs index 35e6f010b..75d5b7a21 100644 --- a/src/Discord.Net/DiscordConfig.cs +++ b/src/Discord.Net/DiscordConfig.cs @@ -3,14 +3,12 @@ using System.Reflection; namespace Discord { - //TODO: Add socket config items in their own class - public class DiscordConfig { public static string Version { get; } = typeof(DiscordConfig).GetTypeInfo().Assembly?.GetName().Version.ToString(3) ?? "Unknown"; public static string UserAgent { get; } = $"DiscordBot (https://github.com/RogueException/Discord.Net, v{Version})"; - public const int GatewayAPIVersion = 3; //TODO: Upgrade to 4 + public const int GatewayAPIVersion = 5; public const string GatewayEncoding = "json"; public const string ClientAPIUrl = "https://discordapp.com/api/"; diff --git a/src/Discord.Net/DiscordSocketClient.Events.cs b/src/Discord.Net/DiscordSocketClient.Events.cs new file mode 100644 index 000000000..092b19674 --- /dev/null +++ b/src/Discord.Net/DiscordSocketClient.Events.cs @@ -0,0 +1,191 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + //TODO: Add event docstrings + public partial class DiscordSocketClient + { + //General + public event Func Connected + { + add { _connectedEvent.Add(value); } + remove { _connectedEvent.Remove(value); } + } + private readonly AsyncEvent> _connectedEvent = new AsyncEvent>(); + public event Func Disconnected + { + add { _disconnectedEvent.Add(value); } + remove { _disconnectedEvent.Remove(value); } + } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + public event Func Ready + { + add { _readyEvent.Add(value); } + remove { _readyEvent.Remove(value); } + } + private readonly AsyncEvent> _readyEvent = new AsyncEvent>(); + public event Func LatencyUpdated + { + add { _latencyUpdatedEvent.Add(value); } + remove { _latencyUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _latencyUpdatedEvent = new AsyncEvent>(); + + //Channels + public event Func ChannelCreated + { + add { _channelCreatedEvent.Add(value); } + remove { _channelCreatedEvent.Remove(value); } + } + private readonly AsyncEvent> _channelCreatedEvent = new AsyncEvent>(); + public event Func ChannelDestroyed + { + add { _channelDestroyedEvent.Add(value); } + remove { _channelDestroyedEvent.Remove(value); } + } + private readonly AsyncEvent> _channelDestroyedEvent = new AsyncEvent>(); + public event Func ChannelUpdated + { + add { _channelUpdatedEvent.Add(value); } + remove { _channelUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _channelUpdatedEvent = new AsyncEvent>(); + + //Messages + public event Func MessageReceived + { + add { _messageReceivedEvent.Add(value); } + remove { _messageReceivedEvent.Remove(value); } + } + private readonly AsyncEvent> _messageReceivedEvent = new AsyncEvent>(); + public event Func, Task> MessageDeleted + { + add { _messageDeletedEvent.Add(value); } + remove { _messageDeletedEvent.Remove(value); } + } + private readonly AsyncEvent, Task>> _messageDeletedEvent = new AsyncEvent, Task>>(); + public event Func, IMessage, Task> MessageUpdated + { + add { _messageUpdatedEvent.Add(value); } + remove { _messageUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent, IMessage, Task>> _messageUpdatedEvent = new AsyncEvent, IMessage, Task>>(); + + //Roles + public event Func RoleCreated + { + add { _roleCreatedEvent.Add(value); } + remove { _roleCreatedEvent.Remove(value); } + } + private readonly AsyncEvent> _roleCreatedEvent = new AsyncEvent>(); + public event Func RoleDeleted + { + add { _roleDeletedEvent.Add(value); } + remove { _roleDeletedEvent.Remove(value); } + } + private readonly AsyncEvent> _roleDeletedEvent = new AsyncEvent>(); + public event Func RoleUpdated + { + add { _roleUpdatedEvent.Add(value); } + remove { _roleUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _roleUpdatedEvent = new AsyncEvent>(); + + //Guilds + public event Func JoinedGuild + { + add { _joinedGuildEvent.Add(value); } + remove { _joinedGuildEvent.Remove(value); } + } + private AsyncEvent> _joinedGuildEvent = new AsyncEvent>(); + public event Func LeftGuild + { + add { _leftGuildEvent.Add(value); } + remove { _leftGuildEvent.Remove(value); } + } + private AsyncEvent> _leftGuildEvent = new AsyncEvent>(); + public event Func GuildAvailable + { + add { _guildAvailableEvent.Add(value); } + remove { _guildAvailableEvent.Remove(value); } + } + private AsyncEvent> _guildAvailableEvent = new AsyncEvent>(); + public event Func GuildUnavailable + { + add { _guildUnavailableEvent.Add(value); } + remove { _guildUnavailableEvent.Remove(value); } + } + private AsyncEvent> _guildUnavailableEvent = new AsyncEvent>(); + public event Func GuildMembersDownloaded + { + add { _guildMembersDownloadedEvent.Add(value); } + remove { _guildMembersDownloadedEvent.Remove(value); } + } + private AsyncEvent> _guildMembersDownloadedEvent = new AsyncEvent>(); + public event Func GuildUpdated + { + add { _guildUpdatedEvent.Add(value); } + remove { _guildUpdatedEvent.Remove(value); } + } + private AsyncEvent> _guildUpdatedEvent = new AsyncEvent>(); + + //Users + public event Func UserJoined + { + add { _userJoinedEvent.Add(value); } + remove { _userJoinedEvent.Remove(value); } + } + private readonly AsyncEvent> _userJoinedEvent = new AsyncEvent>(); + public event Func UserLeft + { + add { _userLeftEvent.Add(value); } + remove { _userLeftEvent.Remove(value); } + } + private readonly AsyncEvent> _userLeftEvent = new AsyncEvent>(); + public event Func UserBanned + { + add { _userBannedEvent.Add(value); } + remove { _userBannedEvent.Remove(value); } + } + private readonly AsyncEvent> _userBannedEvent = new AsyncEvent>(); + public event Func UserUnbanned + { + add { _userUnbannedEvent.Add(value); } + remove { _userUnbannedEvent.Remove(value); } + } + private readonly AsyncEvent> _userUnbannedEvent = new AsyncEvent>(); + public event Func UserUpdated + { + add { _userUpdatedEvent.Add(value); } + remove { _userUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _userUpdatedEvent = new AsyncEvent>(); + public event Func UserPresenceUpdated + { + add { _userPresenceUpdatedEvent.Add(value); } + remove { _userPresenceUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _userPresenceUpdatedEvent = new AsyncEvent>(); + public event Func UserVoiceStateUpdated + { + add { _userVoiceStateUpdatedEvent.Add(value); } + remove { _userVoiceStateUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _userVoiceStateUpdatedEvent = new AsyncEvent>(); + public event Func CurrentUserUpdated + { + add { _selfUpdatedEvent.Add(value); } + remove { _selfUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _selfUpdatedEvent = new AsyncEvent>(); + public event Func UserIsTyping + { + add { _userIsTypingEvent.Add(value); } + remove { _userIsTypingEvent.Remove(value); } + } + private readonly AsyncEvent> _userIsTypingEvent = new AsyncEvent>(); + + //TODO: Add PresenceUpdated? VoiceStateUpdated?, VoiceConnected, VoiceDisconnected; + } +} diff --git a/src/Discord.Net/DiscordSocketClient.cs b/src/Discord.Net/DiscordSocketClient.cs new file mode 100644 index 000000000..c1abfb6d3 --- /dev/null +++ b/src/Discord.Net/DiscordSocketClient.cs @@ -0,0 +1,1262 @@ +using Discord.API.Gateway; +using Discord.Audio; +using Discord.Extensions; +using Discord.Logging; +using Discord.Net.Converters; +using Discord.Net.WebSockets; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord +{ + public partial class DiscordSocketClient : DiscordClient, IDiscordClient + { + private readonly ConcurrentQueue _largeGuilds; + private readonly ILogger _gatewayLogger; +#if BENCHMARK + private readonly ILogger _benchmarkLogger; +#endif + private readonly JsonSerializer _serializer; + + private string _sessionId; + private int _lastSeq; + private ImmutableDictionary _voiceRegions; + private TaskCompletionSource _connectTask; + private CancellationTokenSource _cancelToken; + private Task _heartbeatTask, _guildDownloadTask, _reconnectTask; + private long _heartbeatTime; + private bool _isReconnecting; + private int _unavailableGuilds; + private long _lastGuildAvailableTime; + private int _nextAudioId; + + /// Gets the shard if of this client. + public int ShardId { get; } + /// Gets the current connection state of this client. + public ConnectionState ConnectionState { get; private set; } + /// Gets the estimated round-trip latency, in milliseconds, to the gateway server. + public int Latency { get; private set; } + + //From DiscordConfig + internal int TotalShards { get; private set; } + internal int ConnectionTimeout { get; private set; } + internal int ReconnectDelay { get; private set; } + internal int FailedReconnectDelay { get; private set; } + internal int MessageCacheSize { get; private set; } + internal int LargeThreshold { get; private set; } + internal AudioMode AudioMode { get; private set; } + internal DataStore DataStore { get; private set; } + internal WebSocketProvider WebSocketProvider { get; private set; } + + internal CachedSelfUser CurrentUser => _currentUser as CachedSelfUser; + internal IReadOnlyCollection Guilds => DataStore.Guilds; + internal IReadOnlyCollection DMChannels => DataStore.DMChannels; + internal IReadOnlyCollection VoiceRegions => _voiceRegions.ToReadOnlyCollection(); + + /// Creates a new REST/WebSocket discord client. + public DiscordSocketClient() : this(new DiscordSocketConfig()) { } + /// Creates a new REST/WebSocket discord client. + public DiscordSocketClient(DiscordSocketConfig config) + : base(config) + { + ShardId = config.ShardId; + TotalShards = config.TotalShards; + ConnectionTimeout = config.ConnectionTimeout; + ReconnectDelay = config.ReconnectDelay; + FailedReconnectDelay = config.FailedReconnectDelay; + MessageCacheSize = config.MessageCacheSize; + LargeThreshold = config.LargeThreshold; + AudioMode = config.AudioMode; + WebSocketProvider = config.WebSocketProvider; + _nextAudioId = 1; + + _gatewayLogger = LogManager.CreateLogger("Gateway"); +#if BENCHMARK + _benchmarkLogger = _log.CreateLogger("Benchmark"); +#endif + + _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + _serializer.Error += (s, e) => + { + _gatewayLogger.WarningAsync(e.ErrorContext.Error).GetAwaiter().GetResult(); + e.ErrorContext.Handled = true; + }; + + ApiClient.SentGatewayMessage += async opCode => await _gatewayLogger.DebugAsync($"Sent {opCode}").ConfigureAwait(false); + ApiClient.ReceivedGatewayEvent += ProcessMessageAsync; + ApiClient.Disconnected += async ex => + { + if (ex != null) + { + await _gatewayLogger.WarningAsync($"Connection Closed: {ex.Message}").ConfigureAwait(false); + await StartReconnectAsync(ex).ConfigureAwait(false); + } + else + await _gatewayLogger.WarningAsync($"Connection Closed").ConfigureAwait(false); + }; + + _voiceRegions = ImmutableDictionary.Create(); + _largeGuilds = new ConcurrentQueue(); + } + + protected override async Task OnLoginAsync() + { + var voiceRegions = await ApiClient.GetVoiceRegionsAsync().ConfigureAwait(false); + _voiceRegions = voiceRegions.Select(x => new VoiceRegion(x)).ToImmutableDictionary(x => x.Id); + } + protected override async Task OnLogoutAsync() + { + if (ConnectionState != ConnectionState.Disconnected) + await DisconnectInternalAsync(null).ConfigureAwait(false); + + _voiceRegions = ImmutableDictionary.Create(); + } + + /// + public async Task ConnectAsync(bool waitForGuilds = true) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + _isReconnecting = false; + await ConnectInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + + if (waitForGuilds) + { + var downloadTask = _guildDownloadTask; + if (downloadTask != null) + await _guildDownloadTask.ConfigureAwait(false); + } + } + private async Task ConnectInternalAsync() + { + if (LoginState != LoginState.LoggedIn) + throw new InvalidOperationException("You must log in before connecting."); + + var state = ConnectionState; + if (state == ConnectionState.Connecting || state == ConnectionState.Connected) + await DisconnectInternalAsync(null).ConfigureAwait(false); + + ConnectionState = ConnectionState.Connecting; + await _gatewayLogger.InfoAsync("Connecting").ConfigureAwait(false); + try + { + _connectTask = new TaskCompletionSource(); + _cancelToken = new CancellationTokenSource(); + await ApiClient.ConnectAsync().ConfigureAwait(false); + await _connectedEvent.InvokeAsync().ConfigureAwait(false); + + if (_sessionId != null) + await ApiClient.SendResumeAsync(_sessionId, _lastSeq).ConfigureAwait(false); + else + await ApiClient.SendIdentifyAsync().ConfigureAwait(false); + + await _connectTask.Task.ConfigureAwait(false); + + ConnectionState = ConnectionState.Connected; + await _gatewayLogger.InfoAsync("Connected").ConfigureAwait(false); + } + catch (Exception) + { + await DisconnectInternalAsync(null).ConfigureAwait(false); + throw; + } + } + /// + public async Task DisconnectAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + _isReconnecting = false; + await DisconnectInternalAsync(null).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task DisconnectInternalAsync(Exception ex) + { + ulong guildId; + + if (ConnectionState == ConnectionState.Disconnected) return; + ConnectionState = ConnectionState.Disconnecting; + await _gatewayLogger.InfoAsync("Disconnecting").ConfigureAwait(false); + + //Signal tasks to complete + try { _cancelToken.Cancel(); } catch { } + + //Disconnect from server + await ApiClient.DisconnectAsync().ConfigureAwait(false); + + //Wait for tasks to complete + var heartbeatTask = _heartbeatTask; + if (heartbeatTask != null) + await heartbeatTask.ConfigureAwait(false); + _heartbeatTask = null; + + var guildDownloadTask = _guildDownloadTask; + if (guildDownloadTask != null) + await guildDownloadTask.ConfigureAwait(false); + _guildDownloadTask = null; + + //Clear large guild queue + while (_largeGuilds.TryDequeue(out guildId)) { } + + ConnectionState = ConnectionState.Disconnected; + await _gatewayLogger.InfoAsync("Disconnected").ConfigureAwait(false); + + await _disconnectedEvent.InvokeAsync(ex).ConfigureAwait(false); + } + + private async Task StartReconnectAsync(Exception ex) + { + //TODO: Is this thread-safe? + if (_reconnectTask != null) return; + + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(ex).ConfigureAwait(false); + if (_reconnectTask != null) return; + _isReconnecting = true; + _reconnectTask = ReconnectInternalAsync(); + } + finally { _connectionLock.Release(); } + } + private async Task ReconnectInternalAsync() + { + try + { + int nextReconnectDelay = 1000; + while (_isReconnecting) + { + try + { + await Task.Delay(nextReconnectDelay).ConfigureAwait(false); + nextReconnectDelay *= 2; + if (nextReconnectDelay > 30000) + nextReconnectDelay = 30000; + + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + return; + } + catch (Exception ex) + { + await _gatewayLogger.WarningAsync("Reconnect failed", ex).ConfigureAwait(false); + } + } + } + finally + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + _isReconnecting = false; + _reconnectTask = null; + } + finally { _connectionLock.Release(); } + } + } + + /// + public override Task GetVoiceRegionAsync(string id) + { + VoiceRegion region; + if (_voiceRegions.TryGetValue(id, out region)) + return Task.FromResult(region); + return Task.FromResult(null); + } + + /// + public override Task GetGuildAsync(ulong id) + { + return Task.FromResult(DataStore.GetGuild(id)); + } + public override Task GetGuildEmbedAsync(ulong id) + { + var guild = DataStore.GetGuild(id); + if (guild != null) + return Task.FromResult(new GuildEmbed(guild.IsEmbeddable, guild.EmbedChannelId)); + else + return Task.FromResult(null); + } + public override Task> GetGuildsAsync() + { + return Task.FromResult>(Guilds); + } + internal CachedGuild AddGuild(ExtendedGuild model, DataStore dataStore) + { + var guild = new CachedGuild(this, model, dataStore); + dataStore.AddGuild(guild); + if (model.Large) + _largeGuilds.Enqueue(model.Id); + return guild; + } + internal CachedGuild RemoveGuild(ulong id) + { + var guild = DataStore.RemoveGuild(id); + foreach (var channel in guild.Channels) + guild.RemoveChannel(channel.Id); + foreach (var user in guild.Members) + guild.RemoveUser(user.Id); + return guild; + } + + /// + public override Task GetChannelAsync(ulong id) + { + return Task.FromResult(DataStore.GetChannel(id)); + } + public override Task> GetDMChannelsAsync() + { + return Task.FromResult>(DMChannels); + } + internal CachedDMChannel AddDMChannel(API.Channel model, DataStore dataStore) + { + var recipient = GetOrAddUser(model.Recipient.Value, dataStore); + var channel = new CachedDMChannel(this, new CachedDMUser(recipient), model); + recipient.AddRef(); + dataStore.AddDMChannel(channel); + return channel; + } + internal CachedDMChannel RemoveDMChannel(ulong id) + { + var dmChannel = DataStore.RemoveDMChannel(id); + if (dmChannel != null) + { + var recipient = dmChannel.Recipient; + recipient.User.RemoveRef(this); + } + return dmChannel; + } + + /// + public override Task GetUserAsync(ulong id) + { + return Task.FromResult(DataStore.GetUser(id)); + } + /// + public override Task GetUserAsync(string username, string discriminator) + { + return Task.FromResult(DataStore.Users.Where(x => x.Discriminator == discriminator && x.Username == username).FirstOrDefault()); + } + internal CachedGlobalUser GetOrAddUser(API.User model, DataStore dataStore) + { + var user = dataStore.GetOrAddUser(model.Id, _ => new CachedGlobalUser(model)); + user.AddRef(); + return user; + } + internal CachedGlobalUser RemoveUser(ulong id) + { + return DataStore.RemoveUser(id); + } + + /// Downloads the users list for all large guilds. + public Task DownloadAllUsersAsync() + => DownloadUsersAsync(DataStore.Guilds.Where(x => !x.HasAllMembers)); + /// Downloads the users list for the provided guilds, if they don't have a complete list. + public Task DownloadUsersAsync(IEnumerable guilds) + => DownloadUsersAsync(guilds.Select(x => x as CachedGuild).Where(x => x != null)); + public Task DownloadUsersAsync(params IGuild[] guilds) + => DownloadUsersAsync(guilds.Select(x => x as CachedGuild).Where(x => x != null)); + private async Task DownloadUsersAsync(IEnumerable guilds) + { + var cachedGuilds = guilds.ToArray(); + if (cachedGuilds.Length == 0) return; + + var unsyncedGuilds = guilds.Select(x => x.SyncPromise).Where(x => !x.IsCompleted).ToArray(); + if (unsyncedGuilds.Length > 0) + await Task.WhenAll(unsyncedGuilds); + + //Download offline members + const short batchSize = 50; + + if (cachedGuilds.Length == 1) + { + if (!cachedGuilds[0].HasAllMembers) + await ApiClient.SendRequestMembersAsync(new ulong[] { cachedGuilds[0].Id }).ConfigureAwait(false); + await cachedGuilds[0].DownloaderPromise.ConfigureAwait(false); + return; + } + + ulong[] batchIds = new ulong[Math.Min(batchSize, cachedGuilds.Length)]; + Task[] batchTasks = new Task[batchIds.Length]; + int batchCount = (cachedGuilds.Length + (batchSize - 1)) / batchSize; + + for (int i = 0, k = 0; i < batchCount; i++) + { + bool isLast = i == batchCount - 1; + int count = isLast ? (batchIds.Length - (batchCount - 1) * batchSize) : batchSize; + + for (int j = 0; j < count; j++, k++) + { + var guild = cachedGuilds[k]; + batchIds[j] = guild.Id; + batchTasks[j] = guild.DownloaderPromise; + } + + await ApiClient.SendRequestMembersAsync(batchIds).ConfigureAwait(false); + + if (isLast && batchCount > 1) + await Task.WhenAll(batchTasks.Take(count)).ConfigureAwait(false); + else + await Task.WhenAll(batchTasks).ConfigureAwait(false); + } + } + + public override Task> GetVoiceRegionsAsync() + { + return Task.FromResult>(_voiceRegions.ToReadOnlyCollection()); + } + + private async Task ProcessMessageAsync(GatewayOpCode opCode, int? seq, string type, object payload) + { +#if BENCHMARK + Stopwatch stopwatch = Stopwatch.StartNew(); + try + { +#endif + if (seq != null) + _lastSeq = seq.Value; + try + { + switch (opCode) + { + case GatewayOpCode.Hello: + { + await _gatewayLogger.DebugAsync("Received Hello").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + _heartbeatTime = 0; + _heartbeatTask = RunHeartbeatAsync(data.HeartbeatInterval, _cancelToken.Token); + } + break; + case GatewayOpCode.Heartbeat: + { + await _gatewayLogger.DebugAsync("Received Heartbeat").ConfigureAwait(false); + + await ApiClient.SendHeartbeatAsync(_lastSeq).ConfigureAwait(false); + } + break; + case GatewayOpCode.HeartbeatAck: + { + await _gatewayLogger.DebugAsync("Received HeartbeatAck").ConfigureAwait(false); + + var heartbeatTime = _heartbeatTime; + if (heartbeatTime != 0) + { + int latency = (int)(Environment.TickCount - _heartbeatTime); + _heartbeatTime = 0; + await _gatewayLogger.VerboseAsync($"Latency = {latency} ms").ConfigureAwait(false); + + int before = Latency; + Latency = latency; + + await _latencyUpdatedEvent.InvokeAsync(before, latency).ConfigureAwait(false); + } + } + break; + case GatewayOpCode.InvalidSession: + { + await _gatewayLogger.DebugAsync("Received InvalidSession").ConfigureAwait(false); + await _gatewayLogger.WarningAsync("Failed to resume previous session").ConfigureAwait(false); + + _sessionId = null; + _lastSeq = 0; + await ApiClient.SendIdentifyAsync().ConfigureAwait(false); + } + break; + case GatewayOpCode.Reconnect: + { + await _gatewayLogger.DebugAsync("Received Reconnect").ConfigureAwait(false); + await _gatewayLogger.WarningAsync("Server requested a reconnect").ConfigureAwait(false); + + await StartReconnectAsync(new Exception("Server requested a reconnect")).ConfigureAwait(false); + } + break; + case GatewayOpCode.Dispatch: + switch (type) + { + //Connection + case "READY": + { + await _gatewayLogger.DebugAsync("Received Dispatch (READY)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var dataStore = new DataStore( data.Guilds.Length, data.PrivateChannels.Length); + + var currentUser = new CachedSelfUser(this, data.User); + int unavailableGuilds = 0; + //dataStore.GetOrAddUser(data.User.Id, _ => currentUser); + for (int i = 0; i < data.Guilds.Length; i++) + { + var model = data.Guilds[i]; + AddGuild(model, dataStore); + if (model.Unavailable == true) + unavailableGuilds++; + } + for (int i = 0; i < data.PrivateChannels.Length; i++) + AddDMChannel(data.PrivateChannels[i], dataStore); + + _sessionId = data.SessionId; + _currentUser = currentUser; + _unavailableGuilds = unavailableGuilds; + _lastGuildAvailableTime = Environment.TickCount; + DataStore = dataStore; + + _guildDownloadTask = WaitForGuildsAsync(_cancelToken.Token); + + await _readyEvent.InvokeAsync().ConfigureAwait(false); + await SyncGuildsAsync().ConfigureAwait(false); + + _connectTask.TrySetResult(true); //Signal the .Connect() call to complete + await _gatewayLogger.InfoAsync("Ready").ConfigureAwait(false); + } + break; + case "RESUMED": + { + await _gatewayLogger.DebugAsync("Received Dispatch (RESUMED)").ConfigureAwait(false); + + await _gatewayLogger.InfoAsync("Resumed previous session").ConfigureAwait(false); + } + return; + + //Guilds + case "GUILD_CREATE": + { + var data = (payload as JToken).ToObject(_serializer); + + if (data.Unavailable == false) + { + type = "GUILD_AVAILABLE"; + _lastGuildAvailableTime = Environment.TickCount; + } + await _gatewayLogger.DebugAsync($"Received Dispatch ({type})").ConfigureAwait(false); + + CachedGuild guild; + if (data.Unavailable != false) + { + guild = AddGuild(data, DataStore); + await SyncGuildsAsync().ConfigureAwait(false); + await _joinedGuildEvent.InvokeAsync(guild).ConfigureAwait(false); + await _gatewayLogger.InfoAsync($"Joined {data.Name}").ConfigureAwait(false); + } + else + { + guild = DataStore.GetGuild(data.Id); + if (guild != null) + guild.Update(data, UpdateSource.WebSocket, DataStore); + else + { + await _gatewayLogger.WarningAsync($"{type} referenced an unknown guild.").ConfigureAwait(false); + return; + } + + var unavailableGuilds = _unavailableGuilds; + if (unavailableGuilds != 0) + _unavailableGuilds = unavailableGuilds - 1; + } + + if (data.Unavailable != true) + { + await _gatewayLogger.VerboseAsync($"Connected to {data.Name}").ConfigureAwait(false); + await _guildAvailableEvent.InvokeAsync(guild).ConfigureAwait(false); + } + } + break; + case "GUILD_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.Id); + if (guild != null) + { + var before = guild.Clone(); + guild.Update(data, UpdateSource.WebSocket); + await _guildUpdatedEvent.InvokeAsync(before, guild).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("GUILD_UPDATE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + case "GUILD_EMOJIS_UPDATE": //TODO: Add + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_EMOJIS_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + var before = guild.Clone(); + guild.Update(data, UpdateSource.WebSocket); + await _guildUpdatedEvent.InvokeAsync(before, guild).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("GUILD_EMOJIS_UPDATE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + return; + case "GUILD_INTEGRATIONS_UPDATE": + { + await _gatewayLogger.DebugAsync("Ignored Dispatch (GUILD_INTEGRATIONS_UPDATE)").ConfigureAwait(false); + } + return; + case "GUILD_SYNC": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_SYNC)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.Id); + if (guild != null) + { + var before = guild.Clone(); + guild.Update(data, UpdateSource.WebSocket, DataStore); + await _guildUpdatedEvent.InvokeAsync(before, guild).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("GUILD_SYNC referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + return; + case "GUILD_DELETE": + { + var data = (payload as JToken).ToObject(_serializer); + if (data.Unavailable == true) + type = "GUILD_UNAVAILABLE"; + await _gatewayLogger.DebugAsync($"Received Dispatch ({type})").ConfigureAwait(false); + + var guild = RemoveGuild(data.Id); + if (guild != null) + { + foreach (var member in guild.Members) + member.User.RemoveRef(this); + + await _guildUnavailableEvent.InvokeAsync(guild).ConfigureAwait(false); + await _gatewayLogger.VerboseAsync($"Disconnected from {data.Name}").ConfigureAwait(false); + if (data.Unavailable != true) + { + await _leftGuildEvent.InvokeAsync(guild).ConfigureAwait(false); + await _gatewayLogger.InfoAsync($"Left {data.Name}").ConfigureAwait(false); + } + else + _unavailableGuilds++; + + } + else + { + await _gatewayLogger.WarningAsync($"{type} referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + + //Channels + case "CHANNEL_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (CHANNEL_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + ICachedChannel channel = null; + if (!data.IsPrivate) + { + var guild = DataStore.GetGuild(data.GuildId.Value); + if (guild != null) + guild.AddChannel(data, DataStore); + else + { + await _gatewayLogger.WarningAsync("CHANNEL_CREATE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + else + channel = AddDMChannel(data, DataStore); + if (channel != null) + await _channelCreatedEvent.InvokeAsync(channel).ConfigureAwait(false); + } + break; + case "CHANNEL_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (CHANNEL_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = DataStore.GetChannel(data.Id); + if (channel != null) + { + var before = channel.Clone(); + channel.Update(data, UpdateSource.WebSocket); + await _channelUpdatedEvent.InvokeAsync(before, channel).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("CHANNEL_UPDATE referenced an unknown channel.").ConfigureAwait(false); + return; + } + } + break; + case "CHANNEL_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (CHANNEL_DELETE)").ConfigureAwait(false); + + ICachedChannel channel = null; + var data = (payload as JToken).ToObject(_serializer); + if (!data.IsPrivate) + { + var guild = DataStore.GetGuild(data.GuildId.Value); + if (guild != null) + channel = guild.RemoveChannel(data.Id); + else + { + await _gatewayLogger.WarningAsync("CHANNEL_DELETE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + else + channel = RemoveDMChannel(data.Recipient.Value.Id); + if (channel != null) + await _channelDestroyedEvent.InvokeAsync(channel).ConfigureAwait(false); + else + { + await _gatewayLogger.WarningAsync("CHANNEL_DELETE referenced an unknown channel.").ConfigureAwait(false); + return; + } + } + break; + + //Members + case "GUILD_MEMBER_ADD": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_MEMBER_ADD)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + var user = guild.AddUser(data, DataStore); + await _userJoinedEvent.InvokeAsync(user).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("GUILD_MEMBER_ADD referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + case "GUILD_MEMBER_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_MEMBER_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + var user = guild.GetUser(data.User.Id); + if (user != null) + { + var before = user.Clone(); + user.Update(data, UpdateSource.WebSocket); + await _userUpdatedEvent.InvokeAsync(before, user).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("GUILD_MEMBER_UPDATE referenced an unknown user.").ConfigureAwait(false); + return; + } + } + else + { + await _gatewayLogger.WarningAsync("GUILD_MEMBER_UPDATE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + case "GUILD_MEMBER_REMOVE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_MEMBER_REMOVE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + var user = guild.RemoveUser(data.User.Id); + if (user != null) + { + user.User.RemoveRef(this); + await _userLeftEvent.InvokeAsync(user).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("GUILD_MEMBER_REMOVE referenced an unknown user.").ConfigureAwait(false); + return; + } + } + else + { + await _gatewayLogger.WarningAsync("GUILD_MEMBER_REMOVE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + case "GUILD_MEMBERS_CHUNK": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_MEMBERS_CHUNK)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + foreach (var memberModel in data.Members) + guild.AddUser(memberModel, DataStore); + + if (guild.DownloadedMemberCount >= guild.MemberCount) //Finished downloading for there + { + guild.CompleteDownloadMembers(); + await _guildMembersDownloadedEvent.InvokeAsync(guild).ConfigureAwait(false); + } + } + else + { + await _gatewayLogger.WarningAsync("GUILD_MEMBERS_CHUNK referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + + //Roles + case "GUILD_ROLE_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_ROLE_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + var role = guild.AddRole(data.Role); + await _roleCreatedEvent.InvokeAsync(role).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("GUILD_ROLE_CREATE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + case "GUILD_ROLE_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_ROLE_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + var role = guild.GetRole(data.Role.Id); + if (role != null) + { + var before = role.Clone(); + role.Update(data.Role, UpdateSource.WebSocket); + await _roleUpdatedEvent.InvokeAsync(before, role).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("GUILD_ROLE_UPDATE referenced an unknown role.").ConfigureAwait(false); + return; + } + } + else + { + await _gatewayLogger.WarningAsync("GUILD_ROLE_UPDATE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + case "GUILD_ROLE_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_ROLE_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + var role = guild.RemoveRole(data.RoleId); + if (role != null) + await _roleDeletedEvent.InvokeAsync(role).ConfigureAwait(false); + else + { + await _gatewayLogger.WarningAsync("GUILD_ROLE_DELETE referenced an unknown role.").ConfigureAwait(false); + return; + } + } + else + { + await _gatewayLogger.WarningAsync("GUILD_ROLE_DELETE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + + //Bans + case "GUILD_BAN_ADD": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_BAN_ADD)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + await _userBannedEvent.InvokeAsync(new User(data.User), guild).ConfigureAwait(false); + else + { + await _gatewayLogger.WarningAsync("GUILD_BAN_ADD referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + case "GUILD_BAN_REMOVE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_BAN_REMOVE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + await _userUnbannedEvent.InvokeAsync(new User(data.User), guild).ConfigureAwait(false); + else + { + await _gatewayLogger.WarningAsync("GUILD_BAN_REMOVE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + break; + + //Messages + case "MESSAGE_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = DataStore.GetChannel(data.ChannelId) as ICachedMessageChannel; + if (channel != null) + { + var author = channel.GetUser(data.Author.Value.Id, true); + + if (author != null) + { + var msg = channel.AddMessage(author, data); + await _messageReceivedEvent.InvokeAsync(msg).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("MESSAGE_CREATE referenced an unknown user.").ConfigureAwait(false); + return; + } + } + else + { + await _gatewayLogger.WarningAsync("MESSAGE_CREATE referenced an unknown channel.").ConfigureAwait(false); + return; + } + } + break; + case "MESSAGE_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = DataStore.GetChannel(data.ChannelId) as ICachedMessageChannel; + if (channel != null) + { + IMessage before = null, after = null; + CachedMessage cachedMsg = channel.GetMessage(data.Id); + if (cachedMsg != null) + { + before = cachedMsg.Clone(); + cachedMsg.Update(data, UpdateSource.WebSocket); + after = cachedMsg; + } + else if (data.Author.IsSpecified) + { + //Edited message isnt in cache, create a detached one + var author = channel.GetUser(data.Author.Value.Id, true); + if (author != null) + after = new Message(channel, author, data); + } + if (after != null) + await _messageUpdatedEvent.InvokeAsync(Optional.Create(before), after).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("MESSAGE_UPDATE referenced an unknown channel.").ConfigureAwait(false); + return; + } + } + break; + case "MESSAGE_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = DataStore.GetChannel(data.ChannelId) as ICachedMessageChannel; + if (channel != null) + { + var msg = channel.RemoveMessage(data.Id); + if (msg != null) + await _messageDeletedEvent.InvokeAsync(data.Id, Optional.Create(msg)).ConfigureAwait(false); + else + await _messageDeletedEvent.InvokeAsync(data.Id, Optional.Create()).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("MESSAGE_DELETE referenced an unknown channel.").ConfigureAwait(false); + return; + } + } + break; + case "MESSAGE_DELETE_BULK": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_DELETE_BULK)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = DataStore.GetChannel(data.ChannelId) as ICachedMessageChannel; + if (channel != null) + { + foreach (var id in data.Ids) + { + var msg = channel.RemoveMessage(id); + if (msg != null) + await _messageDeletedEvent.InvokeAsync(id, Optional.Create(msg)).ConfigureAwait(false); + else + await _messageDeletedEvent.InvokeAsync(id, Optional.Create()).ConfigureAwait(false); + } + } + else + { + await _gatewayLogger.WarningAsync("MESSAGE_DELETE_BULK referenced an unknown channel.").ConfigureAwait(false); + return; + } + } + break; + + //Statuses + case "PRESENCE_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (PRESENCE_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (data.GuildId.IsSpecified) + { + var guild = DataStore.GetGuild(data.GuildId.Value); + if (guild == null) + { + await _gatewayLogger.WarningAsync("PRESENCE_UPDATE referenced an unknown guild.").ConfigureAwait(false); + break; + } + + IPresence before; + var user = guild.GetUser(data.User.Id); + if (user != null) + { + before = user.Presence.Clone(); + user.Update(data, UpdateSource.WebSocket); + } + else + { + before = new Presence(null, UserStatus.Offline); + user = guild.AddOrUpdateUser(data, DataStore); + } + + await _userPresenceUpdatedEvent.InvokeAsync(user, before, user).ConfigureAwait(false); + } + else + { + var channel = DataStore.GetDMChannel(data.User.Id); + if (channel != null) + channel.Recipient.Update(data, UpdateSource.WebSocket); + } + } + break; + case "TYPING_START": + { + await _gatewayLogger.DebugAsync("Received Dispatch (TYPING_START)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = DataStore.GetChannel(data.ChannelId) as ICachedMessageChannel; + if (channel != null) + { + var user = channel.GetUser(data.UserId, true); + if (user != null) + await _userIsTypingEvent.InvokeAsync(user, channel).ConfigureAwait(false); + } + } + break; + + //Users + case "USER_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (USER_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (data.Id == CurrentUser.Id) + { + var before = CurrentUser.Clone(); + CurrentUser.Update(data, UpdateSource.WebSocket); + await _selfUpdatedEvent.InvokeAsync(before, CurrentUser).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("Received USER_UPDATE for wrong user.").ConfigureAwait(false); + return; + } + } + break; + + //Voice + case "VOICE_STATE_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (VOICE_STATE_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (data.GuildId.HasValue) + { + var guild = DataStore.GetGuild(data.GuildId.Value); + if (guild != null) + { + VoiceState before, after; + if (data.ChannelId != null) + { + before = guild.GetVoiceState(data.UserId)?.Clone() ?? new VoiceState(null, null, false, false, false); + after = guild.AddOrUpdateVoiceState(data, DataStore); + } + else + { + before = guild.RemoveVoiceState(data.UserId) ?? new VoiceState(null, null, false, false, false); + after = new VoiceState(null, data); + } + + var user = guild.GetUser(data.UserId); + if (user != null) + await _userVoiceStateUpdatedEvent.InvokeAsync(user, before, after).ConfigureAwait(false); + else + { + await _gatewayLogger.WarningAsync("VOICE_STATE_UPDATE referenced an unknown user.").ConfigureAwait(false); + return; + } + } + else + { + await _gatewayLogger.WarningAsync("VOICE_STATE_UPDATE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + } + break; + case "VOICE_SERVER_UPDATE": + await _gatewayLogger.DebugAsync("Received Dispatch (VOICE_SERVER_UPDATE)").ConfigureAwait(false); + + if (AudioMode != AudioMode.Disabled) + { + var data = (payload as JToken).ToObject(_serializer); + var guild = DataStore.GetGuild(data.GuildId); + if (guild != null) + { + string endpoint = data.Endpoint.Substring(0, data.Endpoint.LastIndexOf(':')); + var _ = guild.ConnectAudio(_nextAudioId++, endpoint, data.Token).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("VOICE_SERVER_UPDATE referenced an unknown guild.").ConfigureAwait(false); + return; + } + } + + return; + + //Ignored (User only) + case "USER_SETTINGS_UPDATE": + await _gatewayLogger.DebugAsync("Ignored Dispatch (USER_SETTINGS_UPDATE)").ConfigureAwait(false); + return; + case "MESSAGE_ACK": + await _gatewayLogger.DebugAsync("Ignored Dispatch (MESSAGE_ACK)").ConfigureAwait(false); + return; + + //Others + default: + await _gatewayLogger.WarningAsync($"Unknown Dispatch ({type})").ConfigureAwait(false); + return; + } + break; + default: + await _gatewayLogger.WarningAsync($"Unknown OpCode ({opCode})").ConfigureAwait(false); + return; + } + } + catch (Exception ex) + { + await _gatewayLogger.ErrorAsync($"Error handling {opCode}{(type != null ? $" ({type})" : "")}", ex).ConfigureAwait(false); + return; + } +#if BENCHMARK + } + finally + { + stopwatch.Stop(); + double millis = Math.Round(stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2); + await _benchmarkLogger.DebugAsync($"{millis} ms").ConfigureAwait(false); + } +#endif + } + + private async Task RunHeartbeatAsync(int intervalMillis, CancellationToken cancelToken) + { + //Clean this up when Discord's session patch is live + try + { + while (!cancelToken.IsCancellationRequested) + { + await Task.Delay(intervalMillis, cancelToken).ConfigureAwait(false); + + if (_heartbeatTime != 0) //Server never responded to our last heartbeat + { + if (ConnectionState == ConnectionState.Connected && (_guildDownloadTask?.IsCompleted ?? false)) + { + await _gatewayLogger.WarningAsync("Server missed last heartbeat").ConfigureAwait(false); + await StartReconnectAsync(new Exception("Server missed last heartbeat")).ConfigureAwait(false); + return; + } + } + else + _heartbeatTime = Environment.TickCount; + await ApiClient.SendHeartbeatAsync(_lastSeq).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + } + private async Task WaitForGuildsAsync(CancellationToken cancelToken) + { + while ((_unavailableGuilds != 0) && (Environment.TickCount - _lastGuildAvailableTime < 2000)) + await Task.Delay(500, cancelToken).ConfigureAwait(false); + } + private async Task SyncGuildsAsync() + { + var guildIds = Guilds.Where(x => x.Available).Select(x => x.Id).ToArray(); + if (guildIds.Length > 0) + await ApiClient.SendGuildSyncAsync(guildIds).ConfigureAwait(false); + } + } +} diff --git a/src/Discord.Net/WebSocket/DiscordSocketConfig.cs b/src/Discord.Net/DiscordSocketConfig.cs similarity index 68% rename from src/Discord.Net/WebSocket/DiscordSocketConfig.cs rename to src/Discord.Net/DiscordSocketConfig.cs index 4318bd247..cd54fcd8d 100644 --- a/src/Discord.Net/WebSocket/DiscordSocketConfig.cs +++ b/src/Discord.Net/DiscordSocketConfig.cs @@ -1,7 +1,7 @@ -using Discord.Net.WebSockets; -using Discord.WebSocket.Data; +using Discord.Audio; +using Discord.Net.WebSockets; -namespace Discord.WebSocket +namespace Discord { public class DiscordSocketConfig : DiscordConfig { @@ -15,27 +15,24 @@ namespace Discord.WebSocket /// Gets or sets the time (in milliseconds) to wait after an unexpected disconnect before reconnecting. public int ReconnectDelay { get; set; } = 1000; /// Gets or sets the time (in milliseconds) to wait after an reconnect fails before retrying. - public int FailedReconnectDelay { get; set; } = 15000; + public int FailedReconnectDelay { get; set; } = 15000; /// Gets or sets the number of messages per channel that should be kept in cache. Setting this to zero disables the message cache entirely. - public int MessageCacheSize { get; set; } = 100; - /// + public int MessageCacheSize { get; set; } = 0; + /*/// /// Gets or sets whether the permissions cache should be used. - /// This makes operations such as User.GetPermissions(Channel), User.GuildPermissions, Channel.GetUser, and Channel.Members much faster while increasing memory usage. + /// This makes operations such as User.GetPermissions(Channel), User.GuildPermissions, Channel.GetUser, and Channel.Members much faster at the expense of increased memory usage. /// - public bool UsePermissionsCache { get; set; } = true; - /// Gets or sets whether the a copy of a model is generated on an update event to allow you to check which properties changed. - public bool EnablePreUpdateEvents { get; set; } = true; + public bool UsePermissionsCache { get; set; } = false;*/ /// /// Gets or sets the max number of users a guild may have for offline users to be included in the READY packet. Max is 250. /// Decreasing this may reduce CPU usage while increasing login time and network usage. /// public int LargeThreshold { get; set; } = 250; - //Engines + /// Gets or sets the type of audio this DiscordClient supports. + public AudioMode AudioMode { get; set; } = AudioMode.Disabled; - /// Gets or sets the provider used to generate datastores. - public DataStoreProvider DataStoreProvider { get; set; } = (shardId, totalShards, guildCount, dmCount) => new DefaultDataStore(guildCount, dmCount); /// Gets or sets the provider used to generate new websocket connections. public WebSocketProvider WebSocketProvider { get; set; } = () => new DefaultWebSocketClient(); } diff --git a/src/Discord.Net/Entities/Channels/DMChannel.cs b/src/Discord.Net/Entities/Channels/DMChannel.cs new file mode 100644 index 000000000..7df415b49 --- /dev/null +++ b/src/Discord.Net/Entities/Channels/DMChannel.cs @@ -0,0 +1,127 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal class DMChannel : SnowflakeEntity, IDMChannel + { + public override DiscordClient Discord { get; } + public IUser Recipient { get; private set; } + + public virtual IReadOnlyCollection CachedMessages => ImmutableArray.Create(); + + public DMChannel(DiscordClient discord, IUser recipient, Model model) + : base(model.Id) + { + Discord = discord; + Recipient = recipient; + + Update(model, UpdateSource.Creation); + } + public void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + //TODO: Is this cast okay? + if (Recipient is User) + (Recipient as User).Update(model.Recipient.Value, source); + } + + public async Task UpdateAsync() + { + if (IsAttached) throw new NotSupportedException(); + + var model = await Discord.ApiClient.GetChannelAsync(Id).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task CloseAsync() + { + await Discord.ApiClient.DeleteChannelAsync(Id).ConfigureAwait(false); + } + + public virtual async Task GetUserAsync(ulong id) + { + var currentUser = await Discord.GetCurrentUserAsync().ConfigureAwait(false); + if (id == Recipient.Id) + return Recipient; + else if (id == currentUser.Id) + return currentUser; + else + return null; + } + public virtual async Task> GetUsersAsync() + { + var currentUser = await Discord.GetCurrentUserAsync().ConfigureAwait(false); + return ImmutableArray.Create(currentUser, Recipient); + } + public virtual async Task> GetUsersAsync(int limit, int offset) + { + var currentUser = await Discord.GetCurrentUserAsync().ConfigureAwait(false); + return new IUser[] { currentUser, Recipient }.Skip(offset).Take(limit).ToImmutableArray(); + } + + public async Task SendMessageAsync(string text, bool isTTS) + { + var args = new CreateMessageParams { Content = text, IsTTS = isTTS }; + var model = await Discord.ApiClient.CreateDMMessageAsync(Id, args).ConfigureAwait(false); + return new Message(this, new User(model.Author.Value), model); + } + public async Task SendFileAsync(string filePath, string text, bool isTTS) + { + string filename = Path.GetFileName(filePath); + using (var file = File.OpenRead(filePath)) + { + var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; + var model = await Discord.ApiClient.UploadDMFileAsync(Id, file, args).ConfigureAwait(false); + return new Message(this, new User(model.Author.Value), model); + } + } + public async Task SendFileAsync(Stream stream, string filename, string text, bool isTTS) + { + var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; + var model = await Discord.ApiClient.UploadDMFileAsync(Id, stream, args).ConfigureAwait(false); + return new Message(this, new User(model.Author.Value), model); + } + public virtual async Task GetMessageAsync(ulong id) + { + var model = await Discord.ApiClient.GetChannelMessageAsync(Id, id).ConfigureAwait(false); + if (model != null) + return new Message(this, new User(model.Author.Value), model); + return null; + } + public virtual async Task> GetMessagesAsync(int limit) + { + var args = new GetChannelMessagesParams { Limit = limit }; + var models = await Discord.ApiClient.GetChannelMessagesAsync(Id, args).ConfigureAwait(false); + return models.Select(x => new Message(this, new User(x.Author.Value), x)).ToImmutableArray(); + } + public virtual async Task> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit) + { + var args = new GetChannelMessagesParams { Limit = limit }; + var models = await Discord.ApiClient.GetChannelMessagesAsync(Id, args).ConfigureAwait(false); + return models.Select(x => new Message(this, new User(x.Author.Value), x)).ToImmutableArray(); + } + public async Task DeleteMessagesAsync(IEnumerable messages) + { + await Discord.ApiClient.DeleteDMMessagesAsync(Id, new DeleteMessagesParams { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); + } + + public async Task TriggerTypingAsync() + { + await Discord.ApiClient.TriggerTypingIndicatorAsync(Id).ConfigureAwait(false); + } + + public override string ToString() => '@' + Recipient.ToString(); + private string DebuggerDisplay => $"@{Recipient} ({Id}, DM)"; + + IMessage IMessageChannel.GetCachedMessage(ulong id) => null; + } +} diff --git a/src/Discord.Net/Entities/Channels/GuildChannel.cs b/src/Discord.Net/Entities/Channels/GuildChannel.cs new file mode 100644 index 000000000..78838d242 --- /dev/null +++ b/src/Discord.Net/Entities/Channels/GuildChannel.cs @@ -0,0 +1,156 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal abstract class GuildChannel : SnowflakeEntity, IGuildChannel + { + private List _overwrites; //TODO: Is maintaining a list here too expensive? Is this threadsafe? + + public string Name { get; private set; } + public int Position { get; private set; } + + public Guild Guild { get; private set; } + + public override DiscordClient Discord => Guild.Discord; + + public GuildChannel(Guild guild, Model model) + : base(model.Id) + { + Guild = guild; + + Update(model, UpdateSource.Creation); + } + public virtual void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + Name = model.Name.Value; + Position = model.Position.Value; + + var overwrites = model.PermissionOverwrites.Value; + var newOverwrites = new List(overwrites.Length); + for (int i = 0; i < overwrites.Length; i++) + newOverwrites.Add(new Overwrite(overwrites[i])); + _overwrites = newOverwrites; + } + + public async Task UpdateAsync() + { + if (IsAttached) throw new NotSupportedException(); + + var model = await Discord.ApiClient.GetChannelAsync(Id).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task ModifyAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildChannelParams(); + func(args); + var model = await Discord.ApiClient.ModifyGuildChannelAsync(Id, args).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task DeleteAsync() + { + await Discord.ApiClient.DeleteChannelAsync(Id).ConfigureAwait(false); + } + + public abstract Task GetUserAsync(ulong id); + public abstract Task> GetUsersAsync(); + public abstract Task> GetUsersAsync(int limit, int offset); + + public async Task> GetInvitesAsync() + { + var models = await Discord.ApiClient.GetChannelInvitesAsync(Id).ConfigureAwait(false); + return models.Select(x => new InviteMetadata(Discord, x)).ToImmutableArray(); + } + public async Task CreateInviteAsync(int? maxAge, int? maxUses, bool isTemporary, bool withXkcd) + { + var args = new CreateChannelInviteParams + { + MaxAge = maxAge ?? 0, + MaxUses = maxUses ?? 0, + Temporary = isTemporary, + XkcdPass = withXkcd + }; + var model = await Discord.ApiClient.CreateChannelInviteAsync(Id, args).ConfigureAwait(false); + return new InviteMetadata(Discord, model); + } + + public OverwritePermissions? GetPermissionOverwrite(IUser user) + { + for (int i = 0; i < _overwrites.Count; i++) + { + if (_overwrites[i].TargetId == user.Id) + return _overwrites[i].Permissions; + } + return null; + } + public OverwritePermissions? GetPermissionOverwrite(IRole role) + { + for (int i = 0; i < _overwrites.Count; i++) + { + if (_overwrites[i].TargetId == role.Id) + return _overwrites[i].Permissions; + } + return null; + } + + public async Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions perms) + { + var args = new ModifyChannelPermissionsParams { Allow = perms.AllowValue, Deny = perms.DenyValue }; + await Discord.ApiClient.ModifyChannelPermissionsAsync(Id, user.Id, args).ConfigureAwait(false); + _overwrites.Add(new Overwrite(new API.Overwrite { Allow = perms.AllowValue, Deny = perms.DenyValue, TargetId = user.Id, TargetType = PermissionTarget.User })); + } + public async Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions perms) + { + var args = new ModifyChannelPermissionsParams { Allow = perms.AllowValue, Deny = perms.DenyValue }; + await Discord.ApiClient.ModifyChannelPermissionsAsync(Id, role.Id, args).ConfigureAwait(false); + _overwrites.Add(new Overwrite(new API.Overwrite { Allow = perms.AllowValue, Deny = perms.DenyValue, TargetId = role.Id, TargetType = PermissionTarget.Role })); + } + public async Task RemovePermissionOverwriteAsync(IUser user) + { + await Discord.ApiClient.DeleteChannelPermissionAsync(Id, user.Id).ConfigureAwait(false); + + for (int i = 0; i < _overwrites.Count; i++) + { + if (_overwrites[i].TargetId == user.Id) + { + _overwrites.RemoveAt(i); + return; + } + } + } + public async Task RemovePermissionOverwriteAsync(IRole role) + { + await Discord.ApiClient.DeleteChannelPermissionAsync(Id, role.Id).ConfigureAwait(false); + + for (int i = 0; i < _overwrites.Count; i++) + { + if (_overwrites[i].TargetId == role.Id) + { + _overwrites.RemoveAt(i); + return; + } + } + } + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id})"; + + IGuild IGuildChannel.Guild => Guild; + IReadOnlyCollection IGuildChannel.PermissionOverwrites => _overwrites.AsReadOnly(); + + async Task IChannel.GetUserAsync(ulong id) => await GetUserAsync(id).ConfigureAwait(false); + async Task> IChannel.GetUsersAsync() => await GetUsersAsync().ConfigureAwait(false); + async Task> IChannel.GetUsersAsync(int limit, int offset) => await GetUsersAsync(limit, offset).ConfigureAwait(false); + } +} diff --git a/src/Discord.Net/Entities/Channels/IChannel.cs b/src/Discord.Net/Entities/Channels/IChannel.cs index 74ab4a2f2..f27504e94 100644 --- a/src/Discord.Net/Entities/Channels/IChannel.cs +++ b/src/Discord.Net/Entities/Channels/IChannel.cs @@ -3,13 +3,13 @@ using System.Threading.Tasks; namespace Discord { - public interface IChannel : ISnowflakeEntity + public interface IChannel : ISnowflakeEntity, IUpdateable { /// Gets a collection of all users in this channel. - Task> GetUsers(); + Task> GetUsersAsync(); /// Gets a paginated collection of all users in this channel. - Task> GetUsers(int limit, int offset = 0); + Task> GetUsersAsync(int limit, int offset = 0); /// Gets a user in this channel with the provided id. - Task GetUser(ulong id); + Task GetUserAsync(ulong id); } } diff --git a/src/Discord.Net/Entities/Channels/IDMChannel.cs b/src/Discord.Net/Entities/Channels/IDMChannel.cs index 5038bf36c..b6bbb39d6 100644 --- a/src/Discord.Net/Entities/Channels/IDMChannel.cs +++ b/src/Discord.Net/Entities/Channels/IDMChannel.cs @@ -2,12 +2,12 @@ namespace Discord { - public interface IDMChannel : IMessageChannel, IUpdateable + public interface IDMChannel : IMessageChannel { /// Gets the recipient of all messages in this channel. IUser Recipient { get; } /// Closes this private channel, removing it from your channel list. - Task Close(); + Task CloseAsync(); } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Channels/IGuildChannel.cs b/src/Discord.Net/Entities/Channels/IGuildChannel.cs index 0a6cf2f1b..50da5fa58 100644 --- a/src/Discord.Net/Entities/Channels/IGuildChannel.cs +++ b/src/Discord.Net/Entities/Channels/IGuildChannel.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; namespace Discord { - public interface IGuildChannel : IChannel, IDeletable, IUpdateable + public interface IGuildChannel : IChannel, IDeletable { /// Gets the name of this channel. string Name { get; } @@ -20,32 +20,32 @@ namespace Discord /// The max amount of times this invite may be used. Set to null to have unlimited uses. /// If true, a user accepting this invite will be kicked from the guild after closing their client. /// If true, creates a human-readable link. Not supported if maxAge is set to null. - Task CreateInvite(int? maxAge = 1800, int? maxUses = default(int?), bool isTemporary = false, bool withXkcd = false); + Task CreateInviteAsync(int? maxAge = 1800, int? maxUses = default(int?), bool isTemporary = false, bool withXkcd = false); /// Returns a collection of all invites to this channel. - Task> GetInvites(); + Task> GetInvitesAsync(); /// Gets a collection of permission overwrites for this channel. - IReadOnlyDictionary PermissionOverwrites { get; } - + IReadOnlyCollection PermissionOverwrites { get; } + /// Modifies this guild channel. - Task Modify(Action func); + Task ModifyAsync(Action func); /// Gets the permission overwrite for a specific role, or null if one does not exist. OverwritePermissions? GetPermissionOverwrite(IRole role); /// Gets the permission overwrite for a specific user, or null if one does not exist. OverwritePermissions? GetPermissionOverwrite(IUser user); /// Removes the permission overwrite for the given role, if one exists. - Task RemovePermissionOverwrite(IRole role); + Task RemovePermissionOverwriteAsync(IRole role); /// Removes the permission overwrite for the given user, if one exists. - Task RemovePermissionOverwrite(IUser user); + Task RemovePermissionOverwriteAsync(IUser user); /// Adds or updates the permission overwrite for the given role. - Task AddPermissionOverwrite(IRole role, OverwritePermissions permissions); + Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions); /// Adds or updates the permission overwrite for the given user. - Task AddPermissionOverwrite(IUser user, OverwritePermissions permissions); + Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions); /// Gets a collection of all users in this channel. - new Task> GetUsers(); + new Task> GetUsersAsync(); /// Gets a user in this channel with the provided id. - new Task GetUser(ulong id); + new Task GetUserAsync(ulong id); } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Channels/IMessageChannel.cs b/src/Discord.Net/Entities/Channels/IMessageChannel.cs index e0613da48..a5a73b177 100644 --- a/src/Discord.Net/Entities/Channels/IMessageChannel.cs +++ b/src/Discord.Net/Entities/Channels/IMessageChannel.cs @@ -7,27 +7,27 @@ namespace Discord public interface IMessageChannel : IChannel { /// Gets all messages in this channel's cache. - IEnumerable CachedMessages { get; } + IReadOnlyCollection CachedMessages { get; } - /// Gets the message from this channel's cache with the given id, or null if none was found. - Task GetCachedMessage(ulong id); - - /// Gets the last N messages from this message channel. - Task> GetMessages(int limit = DiscordConfig.MaxMessagesPerBatch); - /// Gets a collection of messages in this channel. - Task> GetMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch); - - /// Sends a message to this text channel. - Task SendMessage(string text, bool isTTS = false); + /// Sends a message to this message channel. + Task SendMessageAsync(string text, bool isTTS = false); /// Sends a file to this text channel, with an optional caption. - Task SendFile(string filePath, string text = null, bool isTTS = false); + Task SendFileAsync(string filePath, string text = null, bool isTTS = false); /// Sends a file to this text channel, with an optional caption. - Task SendFile(Stream stream, string filename, string text = null, bool isTTS = false); - + Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false); + /// Gets a message from this message channel with the given id, or null if not found. + Task GetMessageAsync(ulong id); + /// Gets the message from this channel's cache with the given id, or null if not found. + IMessage GetCachedMessage(ulong id); + /// Gets the last N messages from this message channel. + Task> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch); + /// Gets a collection of messages in this channel. + Task> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch); /// Bulk deletes multiple messages. - Task DeleteMessages(IEnumerable messages); + Task DeleteMessagesAsync(IEnumerable messages); + /// Broadcasts the "user is typing" message to all users in this channel, lasting 10 seconds. - Task TriggerTyping(); + Task TriggerTypingAsync(); } } diff --git a/src/Discord.Net/Entities/Channels/ITextChannel.cs b/src/Discord.Net/Entities/Channels/ITextChannel.cs index fe0578e57..3b4248b6e 100644 --- a/src/Discord.Net/Entities/Channels/ITextChannel.cs +++ b/src/Discord.Net/Entities/Channels/ITextChannel.cs @@ -10,6 +10,6 @@ namespace Discord string Topic { get; } /// Modifies this text channel. - Task Modify(Action func); + Task ModifyAsync(Action func); } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Channels/IVoiceChannel.cs b/src/Discord.Net/Entities/Channels/IVoiceChannel.cs index d94a97a63..5f6e8c817 100644 --- a/src/Discord.Net/Entities/Channels/IVoiceChannel.cs +++ b/src/Discord.Net/Entities/Channels/IVoiceChannel.cs @@ -1,4 +1,5 @@ using Discord.API.Rest; +using Discord.Audio; using System; using System.Threading.Tasks; @@ -12,6 +13,8 @@ namespace Discord int UserLimit { get; } /// Modifies this voice channel. - Task Modify(Action func); + Task ModifyAsync(Action func); + /// Connects to this voice channel. + Task ConnectAsync(); } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Channels/TextChannel.cs b/src/Discord.Net/Entities/Channels/TextChannel.cs new file mode 100644 index 000000000..c32b67c74 --- /dev/null +++ b/src/Discord.Net/Entities/Channels/TextChannel.cs @@ -0,0 +1,116 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal class TextChannel : GuildChannel, ITextChannel + { + public string Topic { get; private set; } + + public string Mention => MentionUtils.Mention(this); + public virtual IReadOnlyCollection CachedMessages => ImmutableArray.Create(); + + public TextChannel(Guild guild, Model model) + : base(guild, model) + { + } + public override void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + Topic = model.Topic.Value; + base.Update(model, source); + } + + public async Task ModifyAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyTextChannelParams(); + func(args); + var model = await Discord.ApiClient.ModifyGuildChannelAsync(Id, args).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + + public override async Task GetUserAsync(ulong id) + { + var user = await Guild.GetUserAsync(id).ConfigureAwait(false); + if (user != null && Permissions.GetValue(Permissions.ResolveChannel(user, this, user.GuildPermissions.RawValue), ChannelPermission.ReadMessages)) + return user; + return null; + } + public override async Task> GetUsersAsync() + { + var users = await Guild.GetUsersAsync().ConfigureAwait(false); + return users.Where(x => Permissions.GetValue(Permissions.ResolveChannel(x, this, x.GuildPermissions.RawValue), ChannelPermission.ReadMessages)).ToImmutableArray(); + } + public override async Task> GetUsersAsync(int limit, int offset) + { + var users = await Guild.GetUsersAsync(limit, offset).ConfigureAwait(false); + return users.Where(x => Permissions.GetValue(Permissions.ResolveChannel(x, this, x.GuildPermissions.RawValue), ChannelPermission.ReadMessages)).ToImmutableArray(); + } + + public async Task SendMessageAsync(string text, bool isTTS) + { + var args = new CreateMessageParams { Content = text, IsTTS = isTTS }; + var model = await Discord.ApiClient.CreateMessageAsync(Guild.Id, Id, args).ConfigureAwait(false); + return new Message(this, new User(model.Author.Value), model); + } + public async Task SendFileAsync(string filePath, string text, bool isTTS) + { + string filename = Path.GetFileName(filePath); + using (var file = File.OpenRead(filePath)) + { + var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; + var model = await Discord.ApiClient.UploadFileAsync(Guild.Id, Id, file, args).ConfigureAwait(false); + return new Message(this, new User(model.Author.Value), model); + } + } + public async Task SendFileAsync(Stream stream, string filename, string text, bool isTTS) + { + var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; + var model = await Discord.ApiClient.UploadFileAsync(Guild.Id, Id, stream, args).ConfigureAwait(false); + return new Message(this, new User(model.Author.Value), model); + } + public virtual async Task GetMessageAsync(ulong id) + { + var model = await Discord.ApiClient.GetChannelMessageAsync(Id, id).ConfigureAwait(false); + if (model != null) + return new Message(this, new User(model.Author.Value), model); + return null; + } + public virtual async Task> GetMessagesAsync(int limit) + { + var args = new GetChannelMessagesParams { Limit = limit }; + var models = await Discord.ApiClient.GetChannelMessagesAsync(Id, args).ConfigureAwait(false); + return models.Select(x => new Message(this, new User(x.Author.Value), x)).ToImmutableArray(); + } + public virtual async Task> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit) + { + var args = new GetChannelMessagesParams { Limit = limit }; + var models = await Discord.ApiClient.GetChannelMessagesAsync(Id, args).ConfigureAwait(false); + return models.Select(x => new Message(this, new User(x.Author.Value), x)).ToImmutableArray(); + } + public async Task DeleteMessagesAsync(IEnumerable messages) + { + await Discord.ApiClient.DeleteMessagesAsync(Guild.Id, Id, new DeleteMessagesParams { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); + } + + public async Task TriggerTypingAsync() + { + await Discord.ApiClient.TriggerTypingIndicatorAsync(Id).ConfigureAwait(false); + } + + private string DebuggerDisplay => $"{Name} ({Id}, Text)"; + + IMessage IMessageChannel.GetCachedMessage(ulong id) => null; + } +} diff --git a/src/Discord.Net/Entities/Channels/VoiceChannel.cs b/src/Discord.Net/Entities/Channels/VoiceChannel.cs new file mode 100644 index 000000000..a7e32d5a4 --- /dev/null +++ b/src/Discord.Net/Entities/Channels/VoiceChannel.cs @@ -0,0 +1,57 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Channel; +using Discord.Audio; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal class VoiceChannel : GuildChannel, IVoiceChannel + { + public int Bitrate { get; private set; } + public int UserLimit { get; private set; } + + public VoiceChannel(Guild guild, Model model) + : base(guild, model) + { + } + public override void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + base.Update(model, source); + Bitrate = model.Bitrate.Value; + UserLimit = model.UserLimit.Value; + } + + public async Task ModifyAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyVoiceChannelParams(); + func(args); + var model = await Discord.ApiClient.ModifyGuildChannelAsync(Id, args).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + + public override Task GetUserAsync(ulong id) + { + throw new NotSupportedException(); + } + public override Task> GetUsersAsync() + { + throw new NotSupportedException(); + } + public override Task> GetUsersAsync(int limit, int offset) + { + throw new NotSupportedException(); + } + + public virtual Task ConnectAsync() { throw new NotSupportedException(); } + + private string DebuggerDisplay => $"{Name} ({Id}, Voice)"; + } +} diff --git a/src/Discord.Net/Entities/Entity.cs b/src/Discord.Net/Entities/Entity.cs new file mode 100644 index 000000000..4ffd45d1f --- /dev/null +++ b/src/Discord.Net/Entities/Entity.cs @@ -0,0 +1,16 @@ +namespace Discord +{ + internal abstract class Entity : IEntity + { + public T Id { get; } + + public abstract DiscordClient Discord { get; } + + public bool IsAttached => this is ICachedEntity; + + public Entity(T id) + { + Id = id; + } + } +} diff --git a/src/Discord.Net/Entities/Guilds/DefaultMessageNotifications.cs b/src/Discord.Net/Entities/Guilds/DefaultMessageNotifications.cs new file mode 100644 index 000000000..efc107537 --- /dev/null +++ b/src/Discord.Net/Entities/Guilds/DefaultMessageNotifications.cs @@ -0,0 +1,10 @@ +namespace Discord +{ + public enum DefaultMessageNotifications + { + /// By default, only mentions will trigger notifications. + MentionsOnly = 0, + /// By default, all messages will trigger notifications. + AllMessages = 1 + } +} diff --git a/src/Discord.Net/Entities/Guilds/Emoji.cs b/src/Discord.Net/Entities/Guilds/Emoji.cs index 9f4478e74..55ca2ede6 100644 --- a/src/Discord.Net/Entities/Guilds/Emoji.cs +++ b/src/Discord.Net/Entities/Guilds/Emoji.cs @@ -11,7 +11,7 @@ namespace Discord public bool RequireColons { get; } public IImmutableList RoleIds { get; } - internal Emoji(Model model) + public Emoji(Model model) { Id = model.Id; Name = model.Name; diff --git a/src/Discord.Net/Entities/Guilds/Guild.cs b/src/Discord.Net/Entities/Guilds/Guild.cs new file mode 100644 index 000000000..0031fad85 --- /dev/null +++ b/src/Discord.Net/Entities/Guilds/Guild.cs @@ -0,0 +1,322 @@ +using Discord.API.Rest; +using Discord.Audio; +using Discord.Extensions; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using EmbedModel = Discord.API.GuildEmbed; +using Model = Discord.API.Guild; +using RoleModel = Discord.API.Role; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal class Guild : SnowflakeEntity, IGuild + { + protected ConcurrentDictionary _roles; + protected string _iconId, _splashId; + + public string Name { get; private set; } + public int AFKTimeout { get; private set; } + public bool IsEmbeddable { get; private set; } + public VerificationLevel VerificationLevel { get; private set; } + public MfaLevel MfaLevel { get; private set; } + public DefaultMessageNotifications DefaultMessageNotifications { get; private set; } + + public override DiscordClient Discord { get; } + public ulong? AFKChannelId { get; private set; } + public ulong? EmbedChannelId { get; private set; } + public ulong OwnerId { get; private set; } + public string VoiceRegionId { get; private set; } + public ImmutableArray Emojis { get; protected set; } + public ImmutableArray Features { get; protected set; } + + public ulong DefaultChannelId => Id; + public string IconUrl => API.CDN.GetGuildIconUrl(Id, _iconId); + public string SplashUrl => API.CDN.GetGuildSplashUrl(Id, _splashId); + + public Role EveryoneRole => GetRole(Id); + public IReadOnlyCollection Roles => _roles.ToReadOnlyCollection(); + + public Guild(DiscordClient discord, Model model) + : base(model.Id) + { + Discord = discord; + + Update(model, UpdateSource.Creation); + } + public void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + AFKChannelId = model.AFKChannelId; + EmbedChannelId = model.EmbedChannelId; + AFKTimeout = model.AFKTimeout; + IsEmbeddable = model.EmbedEnabled; + _iconId = model.Icon; + Name = model.Name; + OwnerId = model.OwnerId; + VoiceRegionId = model.Region; + _splashId = model.Splash; + VerificationLevel = model.VerificationLevel; + MfaLevel = model.MfaLevel; + DefaultMessageNotifications = model.DefaultMessageNotifications; + + if (model.Emojis != null) + { + var emojis = ImmutableArray.CreateBuilder(model.Emojis.Length); + for (int i = 0; i < model.Emojis.Length; i++) + emojis.Add(new Emoji(model.Emojis[i])); + Emojis = emojis.ToImmutableArray(); + } + else + Emojis = ImmutableArray.Create(); + + if (model.Features != null) + Features = model.Features.ToImmutableArray(); + else + Features = ImmutableArray.Create(); + + var roles = new ConcurrentDictionary(1, model.Roles?.Length ?? 0); + if (model.Roles != null) + { + for (int i = 0; i < model.Roles.Length; i++) + roles[model.Roles[i].Id] = new Role(this, model.Roles[i]); + } + _roles = roles; + } + public void Update(EmbedModel model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + IsEmbeddable = model.Enabled; + EmbedChannelId = model.ChannelId; + } + public void Update(IEnumerable models, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + Role role; + foreach (var model in models) + { + if (_roles.TryGetValue(model.Id, out role)) + role.Update(model, UpdateSource.Rest); + } + } + + public async Task UpdateAsync() + { + if (IsAttached) throw new NotSupportedException(); + + var response = await Discord.ApiClient.GetGuildAsync(Id).ConfigureAwait(false); + Update(response, UpdateSource.Rest); + } + public async Task ModifyAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildParams(); + func(args); + var model = await Discord.ApiClient.ModifyGuildAsync(Id, args).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task ModifyEmbedAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildEmbedParams(); + func(args); + var model = await Discord.ApiClient.ModifyGuildEmbedAsync(Id, args).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task ModifyChannelsAsync(IEnumerable args) + { + await Discord.ApiClient.ModifyGuildChannelsAsync(Id, args).ConfigureAwait(false); + } + public async Task ModifyRolesAsync(IEnumerable args) + { + var models = await Discord.ApiClient.ModifyGuildRolesAsync(Id, args).ConfigureAwait(false); + Update(models, UpdateSource.Rest); + } + public async Task LeaveAsync() + { + await Discord.ApiClient.LeaveGuildAsync(Id).ConfigureAwait(false); + } + public async Task DeleteAsync() + { + await Discord.ApiClient.DeleteGuildAsync(Id).ConfigureAwait(false); + } + + public async Task> GetBansAsync() + { + var models = await Discord.ApiClient.GetGuildBansAsync(Id).ConfigureAwait(false); + return models.Select(x => new User(x)).ToImmutableArray(); + } + public Task AddBanAsync(IUser user, int pruneDays = 0) => AddBanAsync(user, pruneDays); + public async Task AddBanAsync(ulong userId, int pruneDays = 0) + { + var args = new CreateGuildBanParams() { PruneDays = pruneDays }; + await Discord.ApiClient.CreateGuildBanAsync(Id, userId, args).ConfigureAwait(false); + } + public Task RemoveBanAsync(IUser user) => RemoveBanAsync(user.Id); + public async Task RemoveBanAsync(ulong userId) + { + await Discord.ApiClient.RemoveGuildBanAsync(Id, userId).ConfigureAwait(false); + } + + public virtual async Task GetChannelAsync(ulong id) + { + var model = await Discord.ApiClient.GetChannelAsync(Id, id).ConfigureAwait(false); + if (model != null) + return ToChannel(model); + return null; + } + public virtual async Task> GetChannelsAsync() + { + var models = await Discord.ApiClient.GetGuildChannelsAsync(Id).ConfigureAwait(false); + return models.Select(x => ToChannel(x)).ToImmutableArray(); + } + public async Task CreateTextChannelAsync(string name) + { + if (name == null) throw new ArgumentNullException(nameof(name)); + + var args = new CreateGuildChannelParams() { Name = name, Type = ChannelType.Text }; + var model = await Discord.ApiClient.CreateGuildChannelAsync(Id, args).ConfigureAwait(false); + return new TextChannel(this, model); + } + public async Task CreateVoiceChannelAsync(string name) + { + if (name == null) throw new ArgumentNullException(nameof(name)); + + var args = new CreateGuildChannelParams { Name = name, Type = ChannelType.Voice }; + var model = await Discord.ApiClient.CreateGuildChannelAsync(Id, args).ConfigureAwait(false); + return new VoiceChannel(this, model); + } + + public async Task> GetIntegrationsAsync() + { + var models = await Discord.ApiClient.GetGuildIntegrationsAsync(Id).ConfigureAwait(false); + return models.Select(x => new GuildIntegration(this, x)).ToImmutableArray(); + } + public async Task CreateIntegrationAsync(ulong id, string type) + { + var args = new CreateGuildIntegrationParams { Id = id, Type = type }; + var model = await Discord.ApiClient.CreateGuildIntegrationAsync(Id, args).ConfigureAwait(false); + return new GuildIntegration(this, model); + } + + public async Task> GetInvitesAsync() + { + var models = await Discord.ApiClient.GetGuildInvitesAsync(Id).ConfigureAwait(false); + return models.Select(x => new InviteMetadata(Discord, x)).ToImmutableArray(); + } + public async Task CreateInviteAsync(int? maxAge = 1800, int? maxUses = null, bool isTemporary = false, bool withXkcd = false) + { + if (maxAge <= 0) throw new ArgumentOutOfRangeException(nameof(maxAge)); + if (maxUses <= 0) throw new ArgumentOutOfRangeException(nameof(maxUses)); + + var args = new CreateChannelInviteParams() + { + MaxAge = maxAge ?? 0, + MaxUses = maxUses ?? 0, + Temporary = isTemporary, + XkcdPass = withXkcd + }; + var model = await Discord.ApiClient.CreateChannelInviteAsync(DefaultChannelId, args).ConfigureAwait(false); + return new InviteMetadata(Discord, model); + } + + public Role GetRole(ulong id) + { + Role result = null; + if (_roles?.TryGetValue(id, out result) == true) + return result; + return null; + } + public async Task CreateRoleAsync(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false) + { + if (name == null) throw new ArgumentNullException(nameof(name)); + + var model = await Discord.ApiClient.CreateGuildRoleAsync(Id).ConfigureAwait(false); + var role = new Role(this, model); + + await role.ModifyAsync(x => + { + x.Name = name; + x.Permissions = (permissions ?? role.Permissions).RawValue; + x.Color = (color ?? Color.Default).RawValue; + x.Hoist = isHoisted; + }).ConfigureAwait(false); + + return role; + } + + public virtual async Task GetUserAsync(ulong id) + { + var model = await Discord.ApiClient.GetGuildMemberAsync(Id, id).ConfigureAwait(false); + if (model != null) + return new GuildUser(this, new User(model.User), model); + return null; + } + public virtual async Task GetCurrentUserAsync() + { + var currentUser = await Discord.GetCurrentUserAsync().ConfigureAwait(false); + return await GetUserAsync(currentUser.Id).ConfigureAwait(false); + } + public virtual async Task> GetUsersAsync() + { + var args = new GetGuildMembersParams(); + var models = await Discord.ApiClient.GetGuildMembersAsync(Id, args).ConfigureAwait(false); + return models.Select(x => new GuildUser(this, new User(x.User), x)).ToImmutableArray(); + } + public virtual async Task> GetUsersAsync(int limit, int offset) + { + var args = new GetGuildMembersParams { Limit = limit, Offset = offset }; + var models = await Discord.ApiClient.GetGuildMembersAsync(Id, args).ConfigureAwait(false); + return models.Select(x => new GuildUser(this, new User(x.User), x)).ToImmutableArray(); + } + public async Task PruneUsersAsync(int days = 30, bool simulate = false) + { + var args = new GuildPruneParams() { Days = days }; + GetGuildPruneCountResponse model; + if (simulate) + model = await Discord.ApiClient.GetGuildPruneCountAsync(Id, args).ConfigureAwait(false); + else + model = await Discord.ApiClient.BeginGuildPruneAsync(Id, args).ConfigureAwait(false); + return model.Pruned; + } + public virtual Task DownloadUsersAsync() + { + throw new NotSupportedException(); + } + + internal GuildChannel ToChannel(API.Channel model) + { + switch (model.Type.Value) + { + case ChannelType.Text: + return new TextChannel(this, model); + case ChannelType.Voice: + return new VoiceChannel(this, model); + default: + throw new InvalidOperationException($"Unknown channel type: {model.Type.Value}"); + } + } + + public override string ToString() => Name; + + private string DebuggerDisplay => $"{Name} ({Id})"; + + bool IGuild.Available => false; + IRole IGuild.EveryoneRole => EveryoneRole; + IReadOnlyCollection IGuild.Emojis => Emojis; + IReadOnlyCollection IGuild.Features => Features; + IAudioClient IGuild.AudioClient => null; + + IRole IGuild.GetRole(ulong id) => GetRole(id); + } +} diff --git a/src/Discord.Net/Entities/Guilds/GuildEmbed.cs b/src/Discord.Net/Entities/Guilds/GuildEmbed.cs new file mode 100644 index 000000000..fdf85abae --- /dev/null +++ b/src/Discord.Net/Entities/Guilds/GuildEmbed.cs @@ -0,0 +1,18 @@ +using Model = Discord.API.GuildEmbed; + +namespace Discord +{ + public struct GuildEmbed + { + public bool IsEnabled { get; private set; } + public ulong? ChannelId { get; private set; } + + public GuildEmbed(bool isEnabled, ulong? channelId) + { + ChannelId = channelId; + IsEnabled = isEnabled; + } + internal GuildEmbed(Model model) + : this(model.Enabled, model.ChannelId) { } + } +} diff --git a/src/Discord.Net/Rest/Entities/Guilds/GuildIntegration.cs b/src/Discord.Net/Entities/Guilds/GuildIntegration.cs similarity index 54% rename from src/Discord.Net/Rest/Entities/Guilds/GuildIntegration.cs rename to src/Discord.Net/Entities/Guilds/GuildIntegration.cs index e368cc8d7..0aba4d4e3 100644 --- a/src/Discord.Net/Rest/Entities/Guilds/GuildIntegration.cs +++ b/src/Discord.Net/Entities/Guilds/GuildIntegration.cs @@ -4,87 +4,75 @@ using System.Diagnostics; using System.Threading.Tasks; using Model = Discord.API.Integration; -namespace Discord.Rest +namespace Discord { [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class GuildIntegration : IGuildIntegration + internal class GuildIntegration : Entity, IGuildIntegration { - /// - public ulong Id { get; private set; } - /// + private long _syncedAtTicks; + public string Name { get; private set; } - /// public string Type { get; private set; } - /// public bool IsEnabled { get; private set; } - /// public bool IsSyncing { get; private set; } - /// public ulong ExpireBehavior { get; private set; } - /// public ulong ExpireGracePeriod { get; private set; } - /// - public DateTime SyncedAt { get; private set; } - /// public Guild Guild { get; private set; } - /// public Role Role { get; private set; } - /// public User User { get; private set; } - /// public IntegrationAccount Account { get; private set; } - internal DiscordClient Discord => Guild.Discord; - - internal GuildIntegration(Guild guild, Model model) + + public override DiscordClient Discord => Guild.Discord; + public DateTimeOffset SyncedAt => DateTimeUtils.FromTicks(_syncedAtTicks); + + public GuildIntegration(Guild guild, Model model) + : base(model.Id) { Guild = guild; - Update(model); + Update(model, UpdateSource.Creation); } - private void Update(Model model) + public void Update(Model model, UpdateSource source) { - Id = model.Id; + if (source == UpdateSource.Rest && IsAttached) return; + Name = model.Name; Type = model.Type; IsEnabled = model.Enabled; IsSyncing = model.Syncing; ExpireBehavior = model.ExpireBehavior; ExpireGracePeriod = model.ExpireGracePeriod; - SyncedAt = model.SyncedAt; + _syncedAtTicks = model.SyncedAt.UtcTicks; Role = Guild.GetRole(model.RoleId); - User = new PublicUser(Discord, model.User); + User = new User(model.User); } - - /// - public async Task Delete() + + public async Task DeleteAsync() { - await Discord.ApiClient.DeleteGuildIntegration(Guild.Id, Id).ConfigureAwait(false); + await Discord.ApiClient.DeleteGuildIntegrationAsync(Guild.Id, Id).ConfigureAwait(false); } - /// - public async Task Modify(Action func) + public async Task ModifyAsync(Action func) { if (func == null) throw new NullReferenceException(nameof(func)); var args = new ModifyGuildIntegrationParams(); func(args); - var model = await Discord.ApiClient.ModifyGuildIntegration(Guild.Id, Id, args).ConfigureAwait(false); + var model = await Discord.ApiClient.ModifyGuildIntegrationAsync(Guild.Id, Id, args).ConfigureAwait(false); - Update(model); + Update(model, UpdateSource.Rest); } - /// - public async Task Sync() + public async Task SyncAsync() { - await Discord.ApiClient.SyncGuildIntegration(Guild.Id, Id).ConfigureAwait(false); + await Discord.ApiClient.SyncGuildIntegrationAsync(Guild.Id, Id).ConfigureAwait(false); } public override string ToString() => Name; private string DebuggerDisplay => $"{Name} ({Id}{(IsEnabled ? ", Enabled" : "")})"; IGuild IGuildIntegration.Guild => Guild; - IRole IGuildIntegration.Role => Role; IUser IGuildIntegration.User => User; - IntegrationAccount IGuildIntegration.Account => Account; + IRole IGuildIntegration.Role => Role; } } diff --git a/src/Discord.Net/Entities/Guilds/IGuild.cs b/src/Discord.Net/Entities/Guilds/IGuild.cs index 2fc618196..8979677ac 100644 --- a/src/Discord.Net/Entities/Guilds/IGuild.cs +++ b/src/Discord.Net/Entities/Guilds/IGuild.cs @@ -2,18 +2,30 @@ using System.Collections.Generic; using System.Threading.Tasks; using Discord.API.Rest; +using Discord.Audio; namespace Discord { public interface IGuild : IDeletable, ISnowflakeEntity, IUpdateable { + /// Gets the name of this guild. + string Name { get; } /// Gets the amount of time (in seconds) a user must be inactive in a voice channel for until they are automatically moved to the AFK voice channel, if one is set. int AFKTimeout { get; } /// Returns true if this guild is embeddable (e.g. widget) bool IsEmbeddable { get; } - /// Gets the name of this guild. - string Name { get; } - int VerificationLevel { get; } + /// Gets the default message notifications for users who haven't explicitly set their notification settings. + DefaultMessageNotifications DefaultMessageNotifications { get; } + /// Gets the level of mfa requirements a user must fulfill before being allowed to perform administrative actions in this guild. + MfaLevel MfaLevel { get; } + /// Gets the level of requirements a user must fulfill before being allowed to post messages in this guild. + VerificationLevel VerificationLevel { get; } + /// Returns the url to this guild's icon, or null if one is not set. + string IconUrl { get; } + /// Returns the url to this guild's splash image, or null if one is not set. + string SplashUrl { get; } + /// Returns true if this guild is currently connected and ready to be used. Only applies to the WebSocket client. + bool Available { get; } /// Gets the id of the AFK voice channel for this guild if set, or null if not. ulong? AFKChannelId { get; } @@ -21,76 +33,76 @@ namespace Discord ulong DefaultChannelId { get; } /// Gets the id of the embed channel for this guild if set, or null if not. ulong? EmbedChannelId { get; } - /// Gets the id of the role containing all users in this guild. - ulong EveryoneRoleId { get; } /// Gets the id of the user that created this guild. ulong OwnerId { get; } - /// Gets the id of the server region hosting this guild's voice channels. + /// Gets the id of the region hosting this guild's voice channels. string VoiceRegionId { get; } - /// Returns the url to this server's icon, or null if one is not set. - string IconUrl { get; } - /// Returns the url to this server's splash image, or null if one is not set. - string SplashUrl { get; } - + /// Gets the IAudioClient currently associated with this guild. + IAudioClient AudioClient { get; } + /// Gets the built-in role containing all users in this guild. + IRole EveryoneRole { get; } /// Gets a collection of all custom emojis for this guild. - IEnumerable Emojis { get; } + IReadOnlyCollection Emojis { get; } /// Gets a collection of all extra features added to this guild. - IEnumerable Features { get; } + IReadOnlyCollection Features { get; } + /// Gets a collection of all roles in this guild. + IReadOnlyCollection Roles { get; } /// Modifies this guild. - Task Modify(Action func); + Task ModifyAsync(Action func); /// Modifies this guild's embed. - Task ModifyEmbed(Action func); + Task ModifyEmbedAsync(Action func); /// Bulk modifies the channels of this guild. - Task ModifyChannels(IEnumerable args); + Task ModifyChannelsAsync(IEnumerable args); /// Bulk modifies the roles of this guild. - Task ModifyRoles(IEnumerable args); + Task ModifyRolesAsync(IEnumerable args); /// Leaves this guild. If you are the owner, use Delete instead. - Task Leave(); + Task LeaveAsync(); /// Gets a collection of all users banned on this guild. - Task> GetBans(); + Task> GetBansAsync(); /// Bans the provided user from this guild and optionally prunes their recent messages. - Task AddBan(IUser user, int pruneDays = 0); + Task AddBanAsync(IUser user, int pruneDays = 0); /// Bans the provided user id from this guild and optionally prunes their recent messages. - Task AddBan(ulong userId, int pruneDays = 0); + Task AddBanAsync(ulong userId, int pruneDays = 0); /// Unbans the provided user if it is currently banned. - Task RemoveBan(IUser user); + Task RemoveBanAsync(IUser user); /// Unbans the provided user id if it is currently banned. - Task RemoveBan(ulong userId); + Task RemoveBanAsync(ulong userId); /// Gets a collection of all channels in this guild. - Task> GetChannels(); + Task> GetChannelsAsync(); /// Gets the channel in this guild with the provided id, or null if not found. - Task GetChannel(ulong id); + Task GetChannelAsync(ulong id); /// Creates a new text channel. - Task CreateTextChannel(string name); + Task CreateTextChannelAsync(string name); /// Creates a new voice channel. - Task CreateVoiceChannel(string name); + Task CreateVoiceChannelAsync(string name); /// Gets a collection of all invites to this guild. - Task> GetInvites(); + Task> GetInvitesAsync(); /// Creates a new invite to this guild. /// The time (in seconds) until the invite expires. Set to null to never expire. /// The max amount of times this invite may be used. Set to null to have unlimited uses. /// If true, a user accepting this invite will be kicked from the guild after closing their client. /// If true, creates a human-readable link. Not supported if maxAge is set to null. - Task CreateInvite(int? maxAge = 1800, int? maxUses = default(int?), bool isTemporary = false, bool withXkcd = false); + Task CreateInviteAsync(int? maxAge = 1800, int? maxUses = default(int?), bool isTemporary = false, bool withXkcd = false); - /// Gets a collection of all roles in this guild. - Task> GetRoles(); /// Gets the role in this guild with the provided id, or null if not found. - Task GetRole(ulong id); + IRole GetRole(ulong id); /// Creates a new role. - Task CreateRole(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false); + Task CreateRoleAsync(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false); /// Gets a collection of all users in this guild. - Task> GetUsers(); + Task> GetUsersAsync(); /// Gets the user in this guild with the provided id, or null if not found. - Task GetUser(ulong id); + Task GetUserAsync(ulong id); /// Gets the current user for this guild. - Task GetCurrentUser(); - Task PruneUsers(int days = 30, bool simulate = false); + Task GetCurrentUserAsync(); + /// Downloads all users for this guild if the current list is incomplete. Only applies to the WebSocket client. + Task DownloadUsersAsync(); + /// Removes all users from this guild if they have not logged on in a provided number of days or, if simulate is true, returns the number of users that would be removed. + Task PruneUsersAsync(int days = 30, bool simulate = false); } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Guilds/IGuildEmbed.cs b/src/Discord.Net/Entities/Guilds/IGuildEmbed.cs deleted file mode 100644 index 0ad23039f..000000000 --- a/src/Discord.Net/Entities/Guilds/IGuildEmbed.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Discord -{ - public interface IGuildEmbed : ISnowflakeEntity - { - bool IsEnabled { get; } - ulong? ChannelId { get; } - } -} diff --git a/src/Discord.Net/Entities/Guilds/IGuildIntegration.cs b/src/Discord.Net/Entities/Guilds/IGuildIntegration.cs index e90d8ae76..7f6ed6408 100644 --- a/src/Discord.Net/Entities/Guilds/IGuildIntegration.cs +++ b/src/Discord.Net/Entities/Guilds/IGuildIntegration.cs @@ -2,6 +2,7 @@ namespace Discord { + //TODO: Add docstrings public interface IGuildIntegration { ulong Id { get; } @@ -11,7 +12,7 @@ namespace Discord bool IsSyncing { get; } ulong ExpireBehavior { get; } ulong ExpireGracePeriod { get; } - DateTime SyncedAt { get; } + DateTimeOffset SyncedAt { get; } IntegrationAccount Account { get; } IGuild Guild { get; } diff --git a/src/Discord.Net/Entities/Guilds/IUserGuild.cs b/src/Discord.Net/Entities/Guilds/IUserGuild.cs index 962602e4a..b27db9377 100644 --- a/src/Discord.Net/Entities/Guilds/IUserGuild.cs +++ b/src/Discord.Net/Entities/Guilds/IUserGuild.cs @@ -4,7 +4,7 @@ { /// Gets the name of this guild. string Name { get; } - /// Returns the url to this server's icon, or null if one is not set. + /// Returns the url to this guild's icon, or null if one is not set. string IconUrl { get; } /// Returns true if the current user owns this guild. bool IsOwner { get; } diff --git a/src/Discord.Net/Entities/Guilds/IVoiceRegion.cs b/src/Discord.Net/Entities/Guilds/IVoiceRegion.cs index 22fa6432c..1a76287d8 100644 --- a/src/Discord.Net/Entities/Guilds/IVoiceRegion.cs +++ b/src/Discord.Net/Entities/Guilds/IVoiceRegion.cs @@ -1,7 +1,9 @@ namespace Discord { - public interface IVoiceRegion : IEntity + public interface IVoiceRegion { + /// Gets the unique identifier for this voice region. + string Id { get; } /// Gets the name of this voice region. string Name { get; } /// Returns true if this voice region is exclusive to VIP accounts. diff --git a/src/Discord.Net/Entities/Guilds/IntegrationAccount.cs b/src/Discord.Net/Entities/Guilds/IntegrationAccount.cs index db0351bb1..71bcf10ed 100644 --- a/src/Discord.Net/Entities/Guilds/IntegrationAccount.cs +++ b/src/Discord.Net/Entities/Guilds/IntegrationAccount.cs @@ -5,10 +5,7 @@ namespace Discord [DebuggerDisplay("{DebuggerDisplay,nq}")] public struct IntegrationAccount { - /// public string Id { get; } - - /// public string Name { get; private set; } public override string ToString() => Name; diff --git a/src/Discord.Net/Entities/Guilds/MfaLevel.cs b/src/Discord.Net/Entities/Guilds/MfaLevel.cs new file mode 100644 index 000000000..1dfef17d5 --- /dev/null +++ b/src/Discord.Net/Entities/Guilds/MfaLevel.cs @@ -0,0 +1,10 @@ +namespace Discord +{ + public enum MfaLevel + { + /// Users have no additional MFA restriction on this guild. + Disabled = 0, + /// Users must have MFA enabled on their account to perform administrative actions. + Enabled = 1 + } +} diff --git a/src/Discord.Net/Rest/Entities/Guilds/UserGuild.cs b/src/Discord.Net/Entities/Guilds/UserGuild.cs similarity index 52% rename from src/Discord.Net/Rest/Entities/Guilds/UserGuild.cs rename to src/Discord.Net/Entities/Guilds/UserGuild.cs index ae5c31da3..9d76817e5 100644 --- a/src/Discord.Net/Rest/Entities/Guilds/UserGuild.cs +++ b/src/Discord.Net/Entities/Guilds/UserGuild.cs @@ -1,53 +1,45 @@ -using System; -using System.Diagnostics; +using System.Diagnostics; using System.Threading.Tasks; using Model = Discord.API.UserGuild; namespace Discord { [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class UserGuild : IUserGuild + internal class UserGuild : SnowflakeEntity, IUserGuild { private string _iconId; - - /// - public ulong Id { get; } - internal IDiscordClient Discord { get; } - - /// + public string Name { get; private set; } public bool IsOwner { get; private set; } public GuildPermissions Permissions { get; private set; } - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - /// + public override DiscordClient Discord { get; } + public string IconUrl => API.CDN.GetGuildIconUrl(Id, _iconId); - internal UserGuild(IDiscordClient discord, Model model) + public UserGuild(DiscordClient discord, Model model) + : base(model.Id) { Discord = discord; - Id = model.Id; - - Update(model); + Update(model, UpdateSource.Creation); } - private void Update(Model model) + public void Update(Model model, UpdateSource source) { + if (source == UpdateSource.Rest && IsAttached) return; + _iconId = model.Icon; IsOwner = model.Owner; Name = model.Name; Permissions = new GuildPermissions(model.Permissions); } - /// - public async Task Leave() + public async Task LeaveAsync() { - await Discord.ApiClient.LeaveGuild(Id).ConfigureAwait(false); + await Discord.ApiClient.LeaveGuildAsync(Id).ConfigureAwait(false); } - /// - public async Task Delete() + public async Task DeleteAsync() { - await Discord.ApiClient.DeleteGuild(Id).ConfigureAwait(false); + await Discord.ApiClient.DeleteGuildAsync(Id).ConfigureAwait(false); } public override string ToString() => Name; diff --git a/src/Discord.Net/Entities/Guilds/VerificationLevel.cs b/src/Discord.Net/Entities/Guilds/VerificationLevel.cs new file mode 100644 index 000000000..d6828b5c9 --- /dev/null +++ b/src/Discord.Net/Entities/Guilds/VerificationLevel.cs @@ -0,0 +1,14 @@ +namespace Discord +{ + public enum VerificationLevel + { + /// Users have no additional restrictions on sending messages to this guild. + None = 0, + /// Users must have a verified email on their account. + Low = 1, + /// Users must fulfill the requirements of Low, and be registered on Discord for at least 5 minutes. + Medium = 2, + /// Users must fulfill the requirements of Medium, and be a member of this guild for at least 10 minutes. + High = 3 + } +} diff --git a/src/Discord.Net/Entities/Guilds/VoiceRegion.cs b/src/Discord.Net/Entities/Guilds/VoiceRegion.cs index 126807202..bf61a33a6 100644 --- a/src/Discord.Net/Entities/Guilds/VoiceRegion.cs +++ b/src/Discord.Net/Entities/Guilds/VoiceRegion.cs @@ -4,22 +4,16 @@ using Model = Discord.API.VoiceRegion; namespace Discord { [DebuggerDisplay("{DebuggerDisplay,nq}")] - public class VoiceRegion : IVoiceRegion + internal class VoiceRegion : IVoiceRegion { - /// public string Id { get; } - /// public string Name { get; } - /// public bool IsVip { get; } - /// public bool IsOptimal { get; } - /// public string SampleHostname { get; } - /// public int SamplePort { get; } - internal VoiceRegion(Model model) + public VoiceRegion(Model model) { Id = model.Id; Name = model.Name; diff --git a/src/Discord.Net/Entities/IDeletable.cs b/src/Discord.Net/Entities/IDeletable.cs index 98887b571..f35f8ad88 100644 --- a/src/Discord.Net/Entities/IDeletable.cs +++ b/src/Discord.Net/Entities/IDeletable.cs @@ -5,6 +5,6 @@ namespace Discord public interface IDeletable { /// Deletes this object and all its children. - Task Delete(); + Task DeleteAsync(); } } diff --git a/src/Discord.Net/Entities/IEntity.cs b/src/Discord.Net/Entities/IEntity.cs index 80f345bda..5d872ca7e 100644 --- a/src/Discord.Net/Entities/IEntity.cs +++ b/src/Discord.Net/Entities/IEntity.cs @@ -4,5 +4,9 @@ namespace Discord { /// Gets the unique identifier for this object. TId Id { get; } + + //TODO: What do we do when an object is destroyed due to reconnect? This summary isn't correct. + /// Returns true if this object is getting live updates from the DiscordClient. + bool IsAttached { get;} } } diff --git a/src/Discord.Net/Entities/ISnowflakeEntity.cs b/src/Discord.Net/Entities/ISnowflakeEntity.cs index 0f0f890cd..60623425c 100644 --- a/src/Discord.Net/Entities/ISnowflakeEntity.cs +++ b/src/Discord.Net/Entities/ISnowflakeEntity.cs @@ -5,6 +5,6 @@ namespace Discord public interface ISnowflakeEntity : IEntity { /// Gets when this object was created. - DateTime CreatedAt { get; } + DateTimeOffset CreatedAt { get; } } } diff --git a/src/Discord.Net/Entities/IUpdateable.cs b/src/Discord.Net/Entities/IUpdateable.cs index eeb31bf88..50b23bb95 100644 --- a/src/Discord.Net/Entities/IUpdateable.cs +++ b/src/Discord.Net/Entities/IUpdateable.cs @@ -4,7 +4,7 @@ namespace Discord { public interface IUpdateable { - /// Ensures this objects's cached properties reflect its current state on the Discord server. - Task Update(); + /// Updates this object's properties with its current state. + Task UpdateAsync(); } } diff --git a/src/Discord.Net/Entities/Invites/IInvite.cs b/src/Discord.Net/Entities/Invites/IInvite.cs index 4b0f55f59..d9da5b3ec 100644 --- a/src/Discord.Net/Entities/Invites/IInvite.cs +++ b/src/Discord.Net/Entities/Invites/IInvite.cs @@ -18,7 +18,7 @@ namespace Discord /// Gets the id of the guild this invite is linked to. ulong GuildId { get; } - /// Accepts this invite and joins the target server. This will fail on bot accounts. - Task Accept(); + /// Accepts this invite and joins the target guild. This will fail on bot accounts. + Task AcceptAsync(); } } diff --git a/src/Discord.Net/Entities/Invites/IInviteMetadata.cs b/src/Discord.Net/Entities/Invites/IInviteMetadata.cs index a2e18a2e7..1136e1678 100644 --- a/src/Discord.Net/Entities/Invites/IInviteMetadata.cs +++ b/src/Discord.Net/Entities/Invites/IInviteMetadata.cs @@ -1,7 +1,11 @@ -namespace Discord +using System; + +namespace Discord { public interface IInviteMetadata : IInvite { + /// Gets the user that created this invite. + IUser Inviter { get; } /// Returns true if this invite was revoked. bool IsRevoked { get; } /// Returns true if users accepting this invite will be removed from the guild when they log off. @@ -12,5 +16,7 @@ int? MaxUses { get; } /// Gets the amount of times this invite has been used. int Uses { get; } + /// Gets when this invite was created. + DateTimeOffset CreatedAt { get; } } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Invites/Invite.cs b/src/Discord.Net/Entities/Invites/Invite.cs index 97e1fc051..90e380582 100644 --- a/src/Discord.Net/Entities/Invites/Invite.cs +++ b/src/Discord.Net/Entities/Invites/Invite.cs @@ -5,38 +5,31 @@ using Model = Discord.API.Invite; namespace Discord { [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class Invite : IInvite + internal class Invite : Entity, IInvite { - /// - public string Code { get; } - internal IDiscordClient Discord { get; } + public string ChannelName { get; private set; } + public string GuildName { get; private set; } + public string XkcdCode { get; private set; } - /// - public ulong GuildId { get; private set; } - /// public ulong ChannelId { get; private set; } - /// - public string XkcdCode { get; private set; } - /// - public string GuildName { get; private set; } - /// - public string ChannelName { get; private set; } + public ulong GuildId { get; private set; } + public override DiscordClient Discord { get; } - /// + public string Code => Id; public string Url => $"{DiscordConfig.InviteUrl}/{XkcdCode ?? Code}"; - /// public string XkcdUrl => XkcdCode != null ? $"{DiscordConfig.InviteUrl}/{XkcdCode}" : null; - - internal Invite(IDiscordClient discord, Model model) + public Invite(DiscordClient discord, Model model) + : base(model.Code) { Discord = discord; - Code = model.Code; - Update(model); + Update(model, UpdateSource.Creation); } - protected virtual void Update(Model model) + public void Update(Model model, UpdateSource source) { + if (source == UpdateSource.Rest && IsAttached) return; + XkcdCode = model.XkcdPass; GuildId = model.Guild.Id; ChannelId = model.Channel.Id; @@ -44,22 +37,16 @@ namespace Discord ChannelName = model.Channel.Name; } - /// - public async Task Accept() + public async Task AcceptAsync() { - await Discord.ApiClient.AcceptInvite(Code).ConfigureAwait(false); + await Discord.ApiClient.AcceptInviteAsync(Code).ConfigureAwait(false); } - - /// - public async Task Delete() + public async Task DeleteAsync() { - await Discord.ApiClient.DeleteInvite(Code).ConfigureAwait(false); + await Discord.ApiClient.DeleteInviteAsync(Code).ConfigureAwait(false); } - /// public override string ToString() => XkcdUrl ?? Url; private string DebuggerDisplay => $"{XkcdUrl ?? Url} ({GuildName} / {ChannelName})"; - - string IEntity.Id => Code; } } diff --git a/src/Discord.Net/Entities/Invites/InviteMetadata.cs b/src/Discord.Net/Entities/Invites/InviteMetadata.cs index 61f353ebd..6c334a79f 100644 --- a/src/Discord.Net/Entities/Invites/InviteMetadata.cs +++ b/src/Discord.Net/Entities/Invites/InviteMetadata.cs @@ -1,32 +1,37 @@ -using Model = Discord.API.InviteMetadata; +using System; +using Model = Discord.API.InviteMetadata; namespace Discord { - public class InviteMetadata : Invite, IInviteMetadata + internal class InviteMetadata : Invite, IInviteMetadata { - /// + private long _createdAtTicks; + public bool IsRevoked { get; private set; } - /// public bool IsTemporary { get; private set; } - /// public int? MaxAge { get; private set; } - /// public int? MaxUses { get; private set; } - /// public int Uses { get; private set; } + public IUser Inviter { get; private set; } + + public DateTimeOffset CreatedAt => DateTimeUtils.FromTicks(_createdAtTicks); - internal InviteMetadata(IDiscordClient client, Model model) + public InviteMetadata(DiscordClient client, Model model) : base(client, model) { - Update(model); + Update(model, UpdateSource.Creation); } - private void Update(Model model) + public void Update(Model model, UpdateSource source) { + if (source == UpdateSource.Rest && IsAttached) return; + + Inviter = new User(model.Inviter); IsRevoked = model.Revoked; IsTemporary = model.Temporary; MaxAge = model.MaxAge != 0 ? model.MaxAge : (int?)null; MaxUses = model.MaxUses; Uses = model.Uses; + _createdAtTicks = model.CreatedAt.UtcTicks; } } } diff --git a/src/Discord.Net/Entities/Messages/Attachment.cs b/src/Discord.Net/Entities/Messages/Attachment.cs index 5f2c1ed47..1cc24749e 100644 --- a/src/Discord.Net/Entities/Messages/Attachment.cs +++ b/src/Discord.Net/Entities/Messages/Attachment.cs @@ -2,17 +2,25 @@ namespace Discord { - public struct Attachment + internal class Attachment : IAttachment { public ulong Id { get; } - public int Size { get; } public string Filename { get; } + public string Url { get; } + public string ProxyUrl { get; } + public int Size { get; } + public int? Height { get; } + public int? Width { get; } public Attachment(Model model) { Id = model.Id; - Size = model.Size; Filename = model.Filename; + Size = model.Size; + Url = model.Url; + ProxyUrl = model.ProxyUrl; + Height = model.Height.IsSpecified ? model.Height.Value : (int?)null; + Width = model.Width.IsSpecified ? model.Width.Value : (int?)null; } } } diff --git a/src/Discord.Net/Entities/Messages/Direction.cs b/src/Discord.Net/Entities/Messages/Direction.cs index c849146ff..5d8d5e621 100644 --- a/src/Discord.Net/Entities/Messages/Direction.cs +++ b/src/Discord.Net/Entities/Messages/Direction.cs @@ -3,6 +3,7 @@ public enum Direction { Before, - After + After, + Around } } diff --git a/src/Discord.Net/Entities/Messages/Embed.cs b/src/Discord.Net/Entities/Messages/Embed.cs index 271e47f66..b27caeac2 100644 --- a/src/Discord.Net/Entities/Messages/Embed.cs +++ b/src/Discord.Net/Entities/Messages/Embed.cs @@ -2,24 +2,26 @@ namespace Discord { - public struct Embed + internal class Embed : IEmbed { + public string Description { get; } public string Url { get; } - public string Type { get; } public string Title { get; } - public string Description { get; } + public string Type { get; } public EmbedProvider Provider { get; } public EmbedThumbnail Thumbnail { get; } - internal Embed(Model model) + public Embed(Model model) { Url = model.Url; Type = model.Type; Title = model.Title; Description = model.Description; - Provider = new EmbedProvider(model.Provider); - Thumbnail = new EmbedThumbnail(model.Thumbnail); + if (model.Provider.IsSpecified) + Provider = new EmbedProvider(model.Provider.Value); + if (model.Thumbnail.IsSpecified) + Thumbnail = new EmbedThumbnail(model.Thumbnail.Value); } } } diff --git a/src/Discord.Net/Entities/Messages/EmbedProvider.cs b/src/Discord.Net/Entities/Messages/EmbedProvider.cs index 2fce8dfe7..1f1ef6d2d 100644 --- a/src/Discord.Net/Entities/Messages/EmbedProvider.cs +++ b/src/Discord.Net/Entities/Messages/EmbedProvider.cs @@ -7,10 +7,12 @@ namespace Discord public string Name { get; } public string Url { get; } - internal EmbedProvider(Model model) + public EmbedProvider(string name, string url) { - Name = model.Name; - Url = model.Url; + Name = name; + Url = url; } + internal EmbedProvider(Model model) + : this(model.Name, model.Url) { } } } diff --git a/src/Discord.Net/Entities/Messages/EmbedThumbnail.cs b/src/Discord.Net/Entities/Messages/EmbedThumbnail.cs index a61323ed6..736d7d743 100644 --- a/src/Discord.Net/Entities/Messages/EmbedThumbnail.cs +++ b/src/Discord.Net/Entities/Messages/EmbedThumbnail.cs @@ -9,12 +9,21 @@ namespace Discord public int? Height { get; } public int? Width { get; } + public EmbedThumbnail(string url, string proxyUrl, int? height, int? width) + { + Url = url; + ProxyUrl = proxyUrl; + Height = height; + Width = width; + } + internal EmbedThumbnail(Model model) + : this( + model.Url, + model.ProxyUrl, + model.Height.IsSpecified ? model.Height.Value : (int?)null, + model.Width.IsSpecified ? model.Width.Value : (int?)null) { - Url = model.Url; - ProxyUrl = model.ProxyUrl; - Height = model.Height; - Width = model.Width; } } } diff --git a/src/Discord.Net/Entities/Messages/IAttachment.cs b/src/Discord.Net/Entities/Messages/IAttachment.cs new file mode 100644 index 000000000..225e9cf2e --- /dev/null +++ b/src/Discord.Net/Entities/Messages/IAttachment.cs @@ -0,0 +1,14 @@ +namespace Discord +{ + public interface IAttachment + { + ulong Id { get; } + + string Filename { get; } + string Url { get; } + string ProxyUrl { get; } + int Size { get; } + int? Height { get; } + int? Width { get; } + } +} diff --git a/src/Discord.Net/Entities/Messages/IEmbed.cs b/src/Discord.Net/Entities/Messages/IEmbed.cs new file mode 100644 index 000000000..e0080f320 --- /dev/null +++ b/src/Discord.Net/Entities/Messages/IEmbed.cs @@ -0,0 +1,12 @@ +namespace Discord +{ + public interface IEmbed + { + string Url { get; } + string Type { get; } + string Title { get; } + string Description { get; } + EmbedProvider Provider { get; } + EmbedThumbnail Thumbnail { get; } + } +} diff --git a/src/Discord.Net/Entities/Messages/IMessage.cs b/src/Discord.Net/Entities/Messages/IMessage.cs index 35107ccf2..e33670acb 100644 --- a/src/Discord.Net/Entities/Messages/IMessage.cs +++ b/src/Discord.Net/Entities/Messages/IMessage.cs @@ -5,36 +5,41 @@ using System.Collections.Generic; namespace Discord { - public interface IMessage : IDeletable, ISnowflakeEntity + public interface IMessage : IDeletable, ISnowflakeEntity, IUpdateable { /// Gets the time of this message's last edit, if any. - DateTime? EditedTimestamp { get; } + DateTimeOffset? EditedTimestamp { get; } /// Returns true if this message was sent as a text-to-speech message. bool IsTTS { get; } + /// Returns true if this message was added to its channel's pinned messages. + bool IsPinned { get; } /// Returns the original, unprocessed text for this message. string RawText { get; } /// Returns the text for this message after mention processing. string Text { get; } /// Gets the time this message was sent. - DateTime Timestamp { get; } //TODO: Is this different from IHasSnowflake.CreatedAt? + DateTimeOffset Timestamp { get; } /// Gets the channel this message was sent to. IMessageChannel Channel { get; } /// Gets the author of this message. IUser Author { get; } - /// Returns a collection of all attachments included in this message. - IReadOnlyList Attachments { get; } + IReadOnlyCollection Attachments { get; } /// Returns a collection of all embeds included in this message. - IReadOnlyList Embeds { get; } + IReadOnlyCollection Embeds { get; } /// Returns a collection of channel ids mentioned in this message. - IReadOnlyList MentionedChannelIds { get; } - /// Returns a collection of role ids mentioned in this message. - IReadOnlyList MentionedRoleIds { get; } - /// Returns a collection of user ids mentioned in this message. - IReadOnlyList MentionedUsers { get; } + IReadOnlyCollection MentionedChannelIds { get; } + /// Returns a collection of roles mentioned in this message. + IReadOnlyCollection MentionedRoles { get; } + /// Returns a collection of users mentioned in this message. + IReadOnlyCollection MentionedUsers { get; } /// Modifies this message. - Task Modify(Action func); + Task ModifyAsync(Action func); + /// Adds this message to its channel's pinned messages. + Task PinAsync(); + /// Removes this message from its channel's pinned messages. + Task UnpinAsync(); } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Messages/Message.cs b/src/Discord.Net/Entities/Messages/Message.cs new file mode 100644 index 000000000..225890578 --- /dev/null +++ b/src/Discord.Net/Entities/Messages/Message.cs @@ -0,0 +1,185 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Message; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal class Message : SnowflakeEntity, IMessage + { + private bool _isMentioningEveryone; + private long _timestampTicks; + private long? _editedTimestampTicks; + + public bool IsTTS { get; private set; } + public string RawText { get; private set; } + public string Text { get; private set; } + public bool IsPinned { get; private set; } + + public IMessageChannel Channel { get; } + public IUser Author { get; } + + public IReadOnlyCollection Attachments { get; private set; } + public IReadOnlyCollection Embeds { get; private set; } + public IReadOnlyCollection MentionedChannelIds { get; private set; } + public IReadOnlyCollection MentionedRoles { get; private set; } + public IReadOnlyCollection MentionedUsers { get; private set; } + + public override DiscordClient Discord => (Channel as Entity).Discord; + public DateTimeOffset? EditedTimestamp => DateTimeUtils.FromTicks(_editedTimestampTicks); + public DateTimeOffset Timestamp => DateTimeUtils.FromTicks(_timestampTicks); + + public Message(IMessageChannel channel, IUser author, Model model) + : base(model.Id) + { + Channel = channel; + Author = author; + + if (channel is IGuildChannel) + { + MentionedUsers = ImmutableArray.Create(); + MentionedChannelIds = ImmutableArray.Create(); + MentionedRoles = ImmutableArray.Create(); + } + + Update(model, UpdateSource.Creation); + } + public void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + var guildChannel = Channel as GuildChannel; + var guild = guildChannel?.Guild; + var discord = Discord; + + if (model.IsTextToSpeech.IsSpecified) + IsTTS = model.IsTextToSpeech.Value; + if (model.Pinned.IsSpecified) + IsPinned = model.Pinned.Value; + if (model.Timestamp.IsSpecified) + _timestampTicks = model.Timestamp.Value.UtcTicks; + if (model.EditedTimestamp.IsSpecified) + _editedTimestampTicks = model.EditedTimestamp.Value?.UtcTicks; + if (model.MentionEveryone.IsSpecified) + _isMentioningEveryone = model.MentionEveryone.Value; + + if (model.Attachments.IsSpecified) + { + var value = model.Attachments.Value; + if (value.Length > 0) + { + var attachments = new Attachment[value.Length]; + for (int i = 0; i < attachments.Length; i++) + attachments[i] = new Attachment(value[i]); + Attachments = ImmutableArray.Create(attachments); + } + else + Attachments = ImmutableArray.Create(); + } + + if (model.Embeds.IsSpecified) + { + var value = model.Embeds.Value; + if (value.Length > 0) + { + var embeds = new Embed[value.Length]; + for (int i = 0; i < embeds.Length; i++) + embeds[i] = new Embed(value[i]); + Embeds = ImmutableArray.Create(embeds); + } + else + Embeds = ImmutableArray.Create(); + } + + if (model.Mentions.IsSpecified) + { + var value = model.Mentions.Value; + if (value.Length > 0) + { + var mentions = new User[value.Length]; + for (int i = 0; i < value.Length; i++) + mentions[i] = new User(value[i]); + MentionedUsers = ImmutableArray.Create(mentions); + } + else + MentionedUsers = ImmutableArray.Create(); + } + + if (model.Content.IsSpecified) + { + RawText = model.Content.Value; + + if (guildChannel != null) + { + var orderedMentionedUsers = ImmutableArray.CreateBuilder(5); + Text = MentionUtils.CleanUserMentions(RawText, Channel.IsAttached ? Channel : null, MentionedUsers, orderedMentionedUsers); + MentionedUsers = orderedMentionedUsers.ToImmutable(); + + var roles = ImmutableArray.CreateBuilder(5); + Text = MentionUtils.CleanRoleMentions(Text, guildChannel.Guild, roles); + MentionedRoles = roles.ToImmutable(); + + if (guildChannel.IsAttached) //It's too expensive to do a channel lookup in REST mode + { + var channelIds = ImmutableArray.CreateBuilder(5); + Text = MentionUtils.CleanChannelMentions(Text, guildChannel.Guild, channelIds); + MentionedChannelIds = channelIds.ToImmutable(); + } + else + MentionedChannelIds = MentionUtils.GetChannelMentions(RawText); + } + else + Text = RawText; + } + } + + public async Task UpdateAsync() + { + if (IsAttached) throw new NotSupportedException(); + + var model = await Discord.ApiClient.GetChannelMessageAsync(Channel.Id, Id).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task ModifyAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyMessageParams(); + func(args); + var guildChannel = Channel as GuildChannel; + + Model model; + if (guildChannel != null) + model = await Discord.ApiClient.ModifyMessageAsync(guildChannel.Guild.Id, Channel.Id, Id, args).ConfigureAwait(false); + else + model = await Discord.ApiClient.ModifyDMMessageAsync(Channel.Id, Id, args).ConfigureAwait(false); + + Update(model, UpdateSource.Rest); + } + public async Task DeleteAsync() + { + var guildChannel = Channel as GuildChannel; + if (guildChannel != null) + await Discord.ApiClient.DeleteMessageAsync(guildChannel.Id, Channel.Id, Id).ConfigureAwait(false); + else + await Discord.ApiClient.DeleteDMMessageAsync(Channel.Id, Id).ConfigureAwait(false); + } + /// Adds this message to its channel's pinned messages. + public async Task PinAsync() + { + await Discord.ApiClient.AddPinAsync(Channel.Id, Id).ConfigureAwait(false); + } + /// Removes this message from its channel's pinned messages. + public async Task UnpinAsync() + { + await Discord.ApiClient.RemovePinAsync(Channel.Id, Id).ConfigureAwait(false); + } + + public override string ToString() => Text; + private string DebuggerDisplay => $"{Author}: {Text}{(Attachments.Count > 0 ? $" [{Attachments.Count} Attachments]" : "")}"; + } +} diff --git a/src/Discord.Net/Entities/Permissions/ChannelPermissions.cs b/src/Discord.Net/Entities/Permissions/ChannelPermissions.cs index f5760f1a9..74938f509 100644 --- a/src/Discord.Net/Entities/Permissions/ChannelPermissions.cs +++ b/src/Discord.Net/Entities/Permissions/ChannelPermissions.cs @@ -7,37 +7,22 @@ namespace Discord [DebuggerDisplay("{DebuggerDisplay,nq}")] public struct ChannelPermissions { -#if CSHARP7 - private static ChannelPermissions _allDM { get; } = new ChannelPermissions(0b000100_000000_0011111111_0000011001); - private static ChannelPermissions _allText { get; } = new ChannelPermissions(0b000000_000000_0001110011_0000000000); - private static ChannelPermissions _allVoice { get; } = new ChannelPermissions(0b000100_111111_0000000000_0000011001); -#else + //TODO: C#7 Candidate for binary literals private static ChannelPermissions _allDM { get; } = new ChannelPermissions(Convert.ToUInt64("00010000000000111111110000011001", 2)); private static ChannelPermissions _allText { get; } = new ChannelPermissions(Convert.ToUInt64("00000000000000011100110000000000", 2)); private static ChannelPermissions _allVoice { get; } = new ChannelPermissions(Convert.ToUInt64("00010011111100000000000000011001", 2)); -#endif /// Gets a blank ChannelPermissions that grants no permissions. public static ChannelPermissions None { get; } = new ChannelPermissions(); /// Gets a ChannelPermissions that grants all permissions for a given channelType. public static ChannelPermissions All(IChannel channel) { -#if CSHARP7 - switch (channel) - { - case ITextChannel _: return _allText; - case IVoiceChannel _: return _allVoice; - case IDMChannel _: return _allDM; - default: - throw new ArgumentException("Unknown channel type", nameof(channel)); - } -#else + //TODO: C#7 Candidate for typeswitch if (channel is ITextChannel) return _allText; if (channel is IVoiceChannel) return _allVoice; if (channel is IDMChannel) return _allDM; throw new ArgumentException("Unknown channel type", nameof(channel)); -#endif } /// Gets a packed value representing all the permissions in this ChannelPermissions. @@ -144,8 +129,8 @@ namespace Discord } return perms; } - /// + public override string ToString() => RawValue.ToString(); - private string DebuggerDisplay => $"{RawValue} ({string.Join(", ", ToList())})"; + private string DebuggerDisplay => $"{string.Join(", ", ToList())}"; } } diff --git a/src/Discord.Net/Entities/Permissions/GuildPermissions.cs b/src/Discord.Net/Entities/Permissions/GuildPermissions.cs index 899cac80a..32aadb603 100644 --- a/src/Discord.Net/Entities/Permissions/GuildPermissions.cs +++ b/src/Discord.Net/Entities/Permissions/GuildPermissions.cs @@ -10,11 +10,8 @@ namespace Discord /// Gets a blank GuildPermissions that grants no permissions. public static readonly GuildPermissions None = new GuildPermissions(); /// Gets a GuildPermissions that grants all permissions. -#if CSHARP7 - public static readonly GuildPermissions All = new GuildPermissions(0b000111_111111_0011111111_0000111111); -#else + //TODO: C#7 Candidate for binary literals public static readonly GuildPermissions All = new GuildPermissions(Convert.ToUInt64("00011111111100111111110000111111", 2)); -#endif /// Gets a packed value representing all the permissions in this GuildPermissions. public ulong RawValue { get; } @@ -144,8 +141,8 @@ namespace Discord } return perms; } - /// + public override string ToString() => RawValue.ToString(); - private string DebuggerDisplay => $"{RawValue} ({string.Join(", ", ToList())})"; + private string DebuggerDisplay => $"{string.Join(", ", ToList())}"; } } diff --git a/src/Discord.Net/Entities/Permissions/Overwrite.cs b/src/Discord.Net/Entities/Permissions/Overwrite.cs index d964e7068..7333d93e1 100644 --- a/src/Discord.Net/Entities/Permissions/Overwrite.cs +++ b/src/Discord.Net/Entities/Permissions/Overwrite.cs @@ -12,11 +12,14 @@ namespace Discord public OverwritePermissions Permissions { get; } /// Creates a new Overwrite with provided target information and modified permissions. - internal Overwrite(Model model) + public Overwrite(ulong targetId, PermissionTarget targetType, OverwritePermissions permissions) { - TargetId = model.TargetId; - TargetType = model.TargetType; - Permissions = new OverwritePermissions(model.Allow, model.Deny); + TargetId = targetId; + TargetType = targetType; + Permissions = permissions; } + + internal Overwrite(Model model) + : this(model.TargetId, model.TargetType, new OverwritePermissions(model.Allow, model.Deny)) { } } } diff --git a/src/Discord.Net/Entities/Permissions/OverwritePermissions.cs b/src/Discord.Net/Entities/Permissions/OverwritePermissions.cs index 7c448522e..009017274 100644 --- a/src/Discord.Net/Entities/Permissions/OverwritePermissions.cs +++ b/src/Discord.Net/Entities/Permissions/OverwritePermissions.cs @@ -135,10 +135,10 @@ namespace Discord } return perms; } - /// + public override string ToString() => $"Allow {AllowValue}, Deny {DenyValue}"; private string DebuggerDisplay => - $"Allow {AllowValue} ({string.Join(", ", ToAllowList())})\n" + - $"Deny {DenyValue} ({string.Join(", ", ToDenyList())})"; + $"Allow {string.Join(", ", ToAllowList())}, " + + $"Deny {string.Join(", ", ToDenyList())}"; } } diff --git a/src/Discord.Net/Entities/Permissions/Permissions.cs b/src/Discord.Net/Entities/Permissions/Permissions.cs index 3cd17e66e..1ce73ccb0 100644 --- a/src/Discord.Net/Entities/Permissions/Permissions.cs +++ b/src/Discord.Net/Entities/Permissions/Permissions.cs @@ -90,8 +90,8 @@ namespace Discord { var roles = user.Roles; ulong newPermissions = 0; - for (int i = 0; i < roles.Count; i++) - newPermissions |= roles[i].Permissions.RawValue; + foreach (var role in roles) + newPermissions |= role.Permissions.RawValue; return newPermissions; } @@ -104,52 +104,40 @@ namespace Discord ulong resolvedPermissions = 0; ulong mask = ChannelPermissions.All(channel).RawValue; - if (user.Id == user.Guild.OwnerId || GetValue(resolvedPermissions, GuildPermission.Administrator)) + if (user.Id == user.Guild.OwnerId || GetValue(guildPermissions, GuildPermission.Administrator)) resolvedPermissions = mask; //Owners and administrators always have all permissions else { //Start with this user's guild permissions resolvedPermissions = guildPermissions; - var overwrites = channel.PermissionOverwrites; - Overwrite entry; + OverwritePermissions? perms; var roles = user.Roles; if (roles.Count > 0) { - for (int i = 0; i < roles.Count; i++) + ulong deniedPermissions = 0UL, allowedPermissions = 0UL; + foreach (var role in roles) { - if (overwrites.TryGetValue(roles[i].Id, out entry)) - resolvedPermissions &= ~entry.Permissions.DenyValue; + perms = channel.GetPermissionOverwrite(role); + if (perms != null) + { + deniedPermissions |= perms.Value.DenyValue; + allowedPermissions |= perms.Value.AllowValue; + } } - for (int i = 0; i < roles.Count; i++) - { - if (overwrites.TryGetValue(roles[i].Id, out entry)) - resolvedPermissions |= entry.Permissions.AllowValue; - } - } - if (overwrites.TryGetValue(user.Id, out entry)) - resolvedPermissions = (resolvedPermissions & ~entry.Permissions.DenyValue) | entry.Permissions.AllowValue; - -#if CSHARP7 - switch (channel) - { - case ITextChannel _: - if (!GetValue(resolvedPermissions, ChannelPermission.ReadMessages)) - resolvedPermissions = 0; //No read permission on a text channel removes all other permissions - break; - case IVoiceChannel _: - if (!GetValue(resolvedPermissions, ChannelPermission.Connect)) - resolvedPermissions = 0; //No read permission on a text channel removes all other permissions - break; + resolvedPermissions = (resolvedPermissions & ~deniedPermissions) | allowedPermissions; } -#else + perms = channel.GetPermissionOverwrite(user); + if (perms != null) + resolvedPermissions = (resolvedPermissions & ~perms.Value.DenyValue) | perms.Value.AllowValue; + + //TODO: C#7 Typeswitch candidate var textChannel = channel as ITextChannel; var voiceChannel = channel as IVoiceChannel; if (textChannel != null && !GetValue(resolvedPermissions, ChannelPermission.ReadMessages)) resolvedPermissions = 0; //No read permission on a text channel removes all other permissions else if (voiceChannel != null && !GetValue(resolvedPermissions, ChannelPermission.Connect)) resolvedPermissions = 0; //No connect permission on a voice channel removes all other permissions -#endif resolvedPermissions &= mask; //Ensure we didnt get any permissions this channel doesnt support (from guildPerms, for example) } diff --git a/src/Discord.Net/Entities/Roles/IRole.cs b/src/Discord.Net/Entities/Roles/IRole.cs index e51769790..29975be46 100644 --- a/src/Discord.Net/Entities/Roles/IRole.cs +++ b/src/Discord.Net/Entities/Roles/IRole.cs @@ -11,7 +11,7 @@ namespace Discord Color Color { get; } /// Returns true if users of this role are separated in the user list. bool IsHoisted { get; } - /// Returns true if this role is automatically managed by the Discord server. + /// Returns true if this role is automatically managed by Discord. bool IsManaged { get; } /// Gets the name of this role. string Name { get; } @@ -24,9 +24,6 @@ namespace Discord ulong GuildId { get; } /// Modifies this role. - Task Modify(Action func); - - /// Returns a collection of all users that have been assigned this role. - Task> GetUsers(); + Task ModifyAsync(Action func); } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Roles/Role.cs b/src/Discord.Net/Entities/Roles/Role.cs new file mode 100644 index 000000000..dd06cc507 --- /dev/null +++ b/src/Discord.Net/Entities/Roles/Role.cs @@ -0,0 +1,68 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Role; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal class Role : SnowflakeEntity, IRole, IMentionable + { + public Guild Guild { get; } + + public Color Color { get; private set; } + public bool IsHoisted { get; private set; } + public bool IsManaged { get; private set; } + public string Name { get; private set; } + public GuildPermissions Permissions { get; private set; } + public int Position { get; private set; } + + public bool IsEveryone => Id == Guild.Id; + public string Mention => MentionUtils.Mention(this); + public override DiscordClient Discord => Guild.Discord; + + public Role(Guild guild, Model model) + : base(model.Id) + { + Guild = guild; + + Update(model, UpdateSource.Creation); + } + public void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + Name = model.Name; + IsHoisted = model.Hoist; + IsManaged = model.Managed; + Position = model.Position; + Color = new Color(model.Color); + Permissions = new GuildPermissions(model.Permissions); + } + + public async Task ModifyAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildRoleParams(); + func(args); + var response = await Discord.ApiClient.ModifyGuildRoleAsync(Guild.Id, Id, args).ConfigureAwait(false); + Update(response, UpdateSource.Rest); + } + public async Task DeleteAsync() + { + await Discord.ApiClient.DeleteGuildRoleAsync(Guild.Id, Id).ConfigureAwait(false); + } + + public Role Clone() => MemberwiseClone() as Role; + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id})"; + + ulong IRole.GuildId => Guild.Id; + } +} diff --git a/src/Discord.Net/Entities/SnowflakeEntity.cs b/src/Discord.Net/Entities/SnowflakeEntity.cs new file mode 100644 index 000000000..36ed8714d --- /dev/null +++ b/src/Discord.Net/Entities/SnowflakeEntity.cs @@ -0,0 +1,15 @@ +using System; + +namespace Discord +{ + internal abstract class SnowflakeEntity : Entity, ISnowflakeEntity + { + //TODO: C#7 Candidate for Extension Property. Lets us remove this class. + public DateTimeOffset CreatedAt => DateTimeUtils.FromSnowflake(Id); + + public SnowflakeEntity(ulong id) + : base(id) + { + } + } +} diff --git a/src/Discord.Net/Entities/UpdateSource.cs b/src/Discord.Net/Entities/UpdateSource.cs new file mode 100644 index 000000000..6c56416e7 --- /dev/null +++ b/src/Discord.Net/Entities/UpdateSource.cs @@ -0,0 +1,9 @@ +namespace Discord +{ + internal enum UpdateSource + { + Creation, + Rest, + WebSocket + } +} diff --git a/src/Discord.Net/Entities/Users/Connection.cs b/src/Discord.Net/Entities/Users/Connection.cs index 10852820e..17507ee44 100644 --- a/src/Discord.Net/Entities/Users/Connection.cs +++ b/src/Discord.Net/Entities/Users/Connection.cs @@ -5,19 +5,18 @@ using Model = Discord.API.Connection; namespace Discord { [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class Connection : IConnection + internal class Connection : IConnection { public string Id { get; } public string Type { get; } public string Name { get; } public bool IsRevoked { get; } - public IEnumerable IntegrationIds { get; } + public IReadOnlyCollection IntegrationIds { get; } public Connection(Model model) { Id = model.Id; - Type = model.Type; Name = model.Name; IsRevoked = model.Revoked; diff --git a/src/Discord.Net/Entities/Users/Game.cs b/src/Discord.Net/Entities/Users/Game.cs index ee5559165..9b5d891ef 100644 --- a/src/Discord.Net/Entities/Users/Game.cs +++ b/src/Discord.Net/Entities/Users/Game.cs @@ -1,22 +1,27 @@ -namespace Discord +using System.Diagnostics; +using Model = Discord.API.Game; + +namespace Discord { - public struct Game + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class Game { public string Name { get; } public string StreamUrl { get; } public StreamType StreamType { get; } - public Game(string name) - { - Name = name; - StreamUrl = null; - StreamType = StreamType.NotStreaming; - } public Game(string name, string streamUrl, StreamType type) { Name = name; StreamUrl = streamUrl; StreamType = type; } + public Game(string name) + : this(name, null, StreamType.NotStreaming) { } + internal Game(Model model) + : this(model.Name, model.StreamUrl.GetValueOrDefault(null), model.StreamType.GetValueOrDefault(null) ?? StreamType.NotStreaming) { } + + public override string ToString() => Name; + private string DebuggerDisplay => StreamUrl != null ? $"{Name} ({StreamUrl})" : Name; } } diff --git a/src/Discord.Net/Entities/Users/GuildUser.cs b/src/Discord.Net/Entities/Users/GuildUser.cs new file mode 100644 index 000000000..5a9e19278 --- /dev/null +++ b/src/Discord.Net/Entities/Users/GuildUser.cs @@ -0,0 +1,157 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.GuildMember; +using PresenceModel = Discord.API.Presence; +using VoiceStateModel = Discord.API.VoiceState; + +namespace Discord +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + internal class GuildUser : IGuildUser, ISnowflakeEntity + { + private long? _joinedAtTicks; + + public string Nickname { get; private set; } + public GuildPermissions GuildPermissions { get; private set; } + + public Guild Guild { get; private set; } + public User User { get; private set; } + public ImmutableArray Roles { get; private set; } + + public ulong Id => User.Id; + public string AvatarUrl => User.AvatarUrl; + public DateTimeOffset CreatedAt => User.CreatedAt; + public string Discriminator => User.Discriminator; + public ushort DiscriminatorValue => User.DiscriminatorValue; + public bool IsAttached => User.IsAttached; + public bool IsBot => User.IsBot; + public string Mention => User.Mention; + public string NicknameMention => User.NicknameMention; + public string Username => User.Username; + + public virtual UserStatus Status => UserStatus.Unknown; + public virtual Game Game => null; + + public DiscordClient Discord => Guild.Discord; + public DateTimeOffset? JoinedAt => DateTimeUtils.FromTicks(_joinedAtTicks); + + public GuildUser(Guild guild, User user) + { + Guild = guild; + User = user; + Roles = ImmutableArray.Create(); + } + public GuildUser(Guild guild, User user, Model model) + : this(guild, user) + { + Update(model, UpdateSource.Creation); + } + public GuildUser(Guild guild, User user, PresenceModel model) + : this(guild, user) + { + Update(model, UpdateSource.Creation); + } + public void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + //if (model.JoinedAt.IsSpecified) + _joinedAtTicks = model.JoinedAt.UtcTicks; + if (model.Nick.IsSpecified) + Nickname = model.Nick.Value; + + //if (model.Roles.IsSpecified) + UpdateRoles(model.Roles); + } + public virtual void Update(PresenceModel model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + if (model.Roles.IsSpecified) + UpdateRoles(model.Roles.Value); + if (model.Nick.IsSpecified) + Nickname = model.Nick.Value; + } + private void UpdateRoles(ulong[] roleIds) + { + var roles = ImmutableArray.CreateBuilder(roleIds.Length + 1); + roles.Add(Guild.EveryoneRole); + for (int i = 0; i < roleIds.Length; i++) + { + var role = Guild.GetRole(roleIds[i]); + if (role != null) + roles.Add(role); + } + Roles = roles.ToImmutable(); + GuildPermissions = new GuildPermissions(Permissions.ResolveGuild(this)); + } + + public async Task UpdateAsync() + { + if (IsAttached) throw new NotSupportedException(); + + var model = await Discord.ApiClient.GetGuildMemberAsync(Guild.Id, Id).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task ModifyAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildMemberParams(); + func(args); + + bool isCurrentUser = (await Discord.GetCurrentUserAsync().ConfigureAwait(false)).Id == Id; + if (isCurrentUser && args.Nickname.IsSpecified) + { + var nickArgs = new ModifyCurrentUserNickParams { Nickname = args.Nickname.Value ?? "" }; + await Discord.ApiClient.ModifyMyNickAsync(Guild.Id, nickArgs).ConfigureAwait(false); + args.Nickname = new Optional(); //Remove + } + + if (!isCurrentUser || args.Deaf.IsSpecified || args.Mute.IsSpecified || args.RoleIds.IsSpecified) + { + await Discord.ApiClient.ModifyGuildMemberAsync(Guild.Id, Id, args).ConfigureAwait(false); + if (args.Nickname.IsSpecified) + Nickname = args.Nickname.Value ?? ""; + if (args.RoleIds.IsSpecified) + Roles = args.RoleIds.Value.Select(x => Guild.GetRole(x)).Where(x => x != null).ToImmutableArray(); + } + } + public async Task KickAsync() + { + await Discord.ApiClient.RemoveGuildMemberAsync(Guild.Id, Id).ConfigureAwait(false); + } + + public override string ToString() => $"{Username}#{Discriminator}"; + private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id})"; + + public ChannelPermissions GetPermissions(IGuildChannel channel) + { + if (channel == null) throw new ArgumentNullException(nameof(channel)); + return new ChannelPermissions(Permissions.ResolveChannel(this, channel, GuildPermissions.RawValue)); + } + + public async Task CreateDMChannelAsync() + { + var args = new CreateDMChannelParams { Recipient = this }; + var model = await Discord.ApiClient.CreateDMChannelAsync(args).ConfigureAwait(false); + + return new DMChannel(Discord, User, model); + } + + IGuild IGuildUser.Guild => Guild; + IReadOnlyCollection IGuildUser.Roles => Roles; + bool IVoiceState.IsDeafened => false; + bool IVoiceState.IsMuted => false; + bool IVoiceState.IsSelfDeafened => false; + bool IVoiceState.IsSelfMuted => false; + bool IVoiceState.IsSuppressed => false; + IVoiceChannel IVoiceState.VoiceChannel => null; + string IVoiceState.VoiceSessionId => null; + } +} diff --git a/src/Discord.Net/Entities/Users/IConnection.cs b/src/Discord.Net/Entities/Users/IConnection.cs index 6540c147e..cc981ccf0 100644 --- a/src/Discord.Net/Entities/Users/IConnection.cs +++ b/src/Discord.Net/Entities/Users/IConnection.cs @@ -9,6 +9,6 @@ namespace Discord string Name { get; } bool IsRevoked { get; } - IEnumerable IntegrationIds { get; } + IReadOnlyCollection IntegrationIds { get; } } } diff --git a/src/Discord.Net/Entities/Users/IGuildUser.cs b/src/Discord.Net/Entities/Users/IGuildUser.cs index f5d17688c..94728723e 100644 --- a/src/Discord.Net/Entities/Users/IGuildUser.cs +++ b/src/Discord.Net/Entities/Users/IGuildUser.cs @@ -6,32 +6,29 @@ using Discord.API.Rest; namespace Discord { /// A Guild-User pairing. - public interface IGuildUser : IUpdateable, IUser + public interface IGuildUser : IUpdateable, IUser, IVoiceState { - /// Returns true if the guild has deafened this user. - bool IsDeaf { get; } - /// Returns true if the guild has muted this user. - bool IsMute { get; } /// Gets when this user joined this guild. - DateTime JoinedAt { get; } + DateTimeOffset? JoinedAt { get; } /// Gets the nickname for this user. string Nickname { get; } + /// Gets the guild-level permissions granted to this user by their roles. + GuildPermissions GuildPermissions { get; } /// Gets the guild for this guild-user pair. IGuild Guild { get; } /// Returns a collection of the roles this user is a member of in this guild, including the guild's @everyone role. - IReadOnlyList Roles { get; } - /// Gets the voice channel this user is currently in, if any. - IVoiceChannel VoiceChannel { get; } + IReadOnlyCollection Roles { get; } - /// Gets the guild-level permissions granted to this user by their roles. - GuildPermissions GetGuildPermissions(); /// Gets the channel-level permissions granted to this user for a given channel. ChannelPermissions GetPermissions(IGuildChannel channel); /// Kicks this user from this guild. - Task Kick(); + Task KickAsync(); /// Modifies this user's properties in this guild. - Task Modify(Action func); + Task ModifyAsync(Action func); + + /// Returns a private message channel to this user, creating one if it does not already exist. + Task CreateDMChannelAsync(); } -} \ No newline at end of file +} diff --git a/src/Discord.Net/Entities/Users/IPresence.cs b/src/Discord.Net/Entities/Users/IPresence.cs new file mode 100644 index 000000000..af7be998a --- /dev/null +++ b/src/Discord.Net/Entities/Users/IPresence.cs @@ -0,0 +1,10 @@ +namespace Discord +{ + public interface IPresence + { + /// Gets the game this user is currently playing, if any. + Game Game { get; } + /// Gets the current status of this user. + UserStatus Status { get; } + } +} \ No newline at end of file diff --git a/src/Discord.Net/Entities/Users/ISelfUser.cs b/src/Discord.Net/Entities/Users/ISelfUser.cs index d6e7c7718..b6803ccf6 100644 --- a/src/Discord.Net/Entities/Users/ISelfUser.cs +++ b/src/Discord.Net/Entities/Users/ISelfUser.cs @@ -10,7 +10,10 @@ namespace Discord string Email { get; } /// Returns true if this user's email has been verified. bool IsVerified { get; } + /// Returns true if this user has enabled MFA on their account. + bool IsMfaEnabled { get; } - Task Modify(Action func); + Task ModifyAsync(Action func); + Task ModifyStatusAsync(Action func); } } \ No newline at end of file diff --git a/src/Discord.Net/Entities/Users/IUser.cs b/src/Discord.Net/Entities/Users/IUser.cs index c4754f3e3..9e78781d2 100644 --- a/src/Discord.Net/Entities/Users/IUser.cs +++ b/src/Discord.Net/Entities/Users/IUser.cs @@ -1,24 +1,18 @@ -using System.Threading.Tasks; - namespace Discord { - public interface IUser : ISnowflakeEntity, IMentionable + public interface IUser : ISnowflakeEntity, IMentionable, IPresence { /// Gets the url to this user's avatar. string AvatarUrl { get; } - /// Gets the game this user is currently playing, if any. - Game? CurrentGame { get; } /// Gets the per-username unique id for this user. - ushort Discriminator { get; } + string Discriminator { get; } + /// Gets the per-username unique id for this user. + ushort DiscriminatorValue { get; } /// Returns true if this user is a bot account. bool IsBot { get; } - /// Gets the current status of this user. - UserStatus Status { get; } /// Gets the username for this user. string Username { get; } - - //TODO: CreateDMChannel is a candidate to move to IGuildUser, and User made a common class, depending on next friends list update - /// Returns a private message channel to this user, creating one if it does not already exist. - Task CreateDMChannel(); + /// Returns a special string used to mention this object, by nickname. + string NicknameMention { get; } } } diff --git a/src/Discord.Net/Entities/Users/IVoiceState.cs b/src/Discord.Net/Entities/Users/IVoiceState.cs new file mode 100644 index 000000000..428601f2a --- /dev/null +++ b/src/Discord.Net/Entities/Users/IVoiceState.cs @@ -0,0 +1,20 @@ +namespace Discord +{ + public interface IVoiceState + { + /// Returns true if the guild has deafened this user. + bool IsDeafened { get; } + /// Returns true if the guild has muted this user. + bool IsMuted { get; } + /// Returns true if this user has marked themselves as deafened. + bool IsSelfDeafened { get; } + /// Returns true if this user has marked themselves as muted. + bool IsSelfMuted { get; } + /// Returns true if the guild is temporarily blocking audio to/from this user. + bool IsSuppressed { get; } + /// Gets the voice channel this user is currently in, if any. + IVoiceChannel VoiceChannel { get; } + /// Gets the unique identifier for this user's voice session. + string VoiceSessionId { get; } + } +} diff --git a/src/Discord.Net/Entities/Users/IVoiceState.cs.old b/src/Discord.Net/Entities/Users/IVoiceState.cs.old deleted file mode 100644 index 5044126a9..000000000 --- a/src/Discord.Net/Entities/Users/IVoiceState.cs.old +++ /dev/null @@ -1,62 +0,0 @@ -/*using System; -using Model = Discord.API.MemberVoiceState; - -namespace Discord.WebSocket -{ - public class VoiceState - { - [Flags] - private enum VoiceStates : byte - { - None = 0x0, - Muted = 0x01, - Deafened = 0x02, - Suppressed = 0x4, - SelfMuted = 0x10, - SelfDeafened = 0x20, - } - - private VoiceStates _voiceStates; - - public Guild Guild { get; } - public ulong UserId { get; } - - /// Gets this user's current voice channel. - public VoiceChannel VoiceChannel { get; internal set; } - - /// Returns true if this user has marked themselves as muted. - public bool IsSelfMuted => (_voiceStates & VoiceStates.SelfMuted) != 0; - /// Returns true if this user has marked themselves as deafened. - public bool IsSelfDeafened => (_voiceStates & VoiceStates.SelfDeafened) != 0; - /// Returns true if the guild is blocking audio from this user. - public bool IsMuted => (_voiceStates & VoiceStates.Muted) != 0; - /// Returns true if the guild is blocking audio to this user. - public bool IsDeafened => (_voiceStates & VoiceStates.Deafened) != 0; - /// Returns true if the guild is temporarily blocking audio to/from this user. - public bool IsSuppressed => (_voiceStates & VoiceStates.Suppressed) != 0; - - internal VoiceState(ulong userId, Guild guild) - { - UserId = userId; - Guild = guild; - } - - internal void Update(Model model) - { - if (model.IsMuted == true) - _voiceStates |= VoiceStates.Muted; - else if (model.IsMuted == false) - _voiceStates &= ~VoiceStates.Muted; - - if (model.IsDeafened == true) - _voiceStates |= VoiceStates.Deafened; - else if (model.IsDeafened == false) - _voiceStates &= ~VoiceStates.Deafened; - - if (model.IsSuppressed == true) - _voiceStates |= VoiceStates.Suppressed; - else if (model.IsSuppressed == false) - _voiceStates &= ~VoiceStates.Suppressed; - } - } -}*/ \ No newline at end of file diff --git a/src/Discord.Net/Entities/Users/SelfUser.cs b/src/Discord.Net/Entities/Users/SelfUser.cs new file mode 100644 index 000000000..0e696aea4 --- /dev/null +++ b/src/Discord.Net/Entities/Users/SelfUser.cs @@ -0,0 +1,82 @@ +using Discord.API.Rest; +using System; +using System.Threading.Tasks; +using Model = Discord.API.User; + +namespace Discord +{ + internal class SelfUser : User, ISelfUser + { + private long _idleSince; + private UserStatus _status; + private Game _game; + + public string Email { get; private set; } + public bool IsVerified { get; private set; } + public bool IsMfaEnabled { get; private set; } + + public override UserStatus Status => _status; + public override Game Game => _game; + + public override DiscordClient Discord { get; } + + public SelfUser(DiscordClient discord, Model model) + : base(model) + { + Discord = discord; + } + public override void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + base.Update(model, source); + + Email = model.Email; + IsVerified = model.Verified; + IsMfaEnabled = model.MfaEnabled; + } + + public async Task UpdateAsync() + { + if (IsAttached) throw new NotSupportedException(); + + var model = await Discord.ApiClient.GetSelfAsync().ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task ModifyAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyCurrentUserParams(); + func(args); + + if (!args.Username.IsSpecified) + args.Username = Username; + + var model = await Discord.ApiClient.ModifySelfAsync(args).ConfigureAwait(false); + Update(model, UpdateSource.Rest); + } + public async Task ModifyStatusAsync(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyPresenceParams(); + func(args); + + var game = args.Game.GetValueOrDefault(_game); + var status = args.Status.GetValueOrDefault(_status); + + long idleSince = _idleSince; + if (status == UserStatus.Idle && _status != UserStatus.Idle) + idleSince = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var apiGame = new API.Game { Name = game.Name, StreamType = game.StreamType, StreamUrl = game.StreamUrl }; + + await Discord.ApiClient.SendStatusUpdateAsync(status == UserStatus.Idle ? _idleSince : (long?)null, apiGame).ConfigureAwait(false); + + //Save values + _idleSince = idleSince; + _game = game; + _status = status; + } + } +} diff --git a/src/Discord.Net/Entities/Users/User.cs b/src/Discord.Net/Entities/Users/User.cs new file mode 100644 index 000000000..490128e82 --- /dev/null +++ b/src/Discord.Net/Entities/Users/User.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics; +using Model = Discord.API.User; + +namespace Discord +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + internal class User : SnowflakeEntity, IUser + { + private string _avatarId; + + public bool IsBot { get; private set; } + public string Username { get; private set; } + public ushort DiscriminatorValue { get; private set; } + + public override DiscordClient Discord { get { throw new NotSupportedException(); } } + + public string AvatarUrl => API.CDN.GetUserAvatarUrl(Id, _avatarId); + public string Discriminator => DiscriminatorValue.ToString("D4"); + public string Mention => MentionUtils.Mention(this, false); + public string NicknameMention => MentionUtils.Mention(this, true); + public virtual Game Game => null; + public virtual UserStatus Status => UserStatus.Unknown; + + public User(Model model) + : base(model.Id) + { + Update(model, UpdateSource.Creation); + } + public virtual void Update(Model model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + _avatarId = model.Avatar; + DiscriminatorValue = ushort.Parse(model.Discriminator); + IsBot = model.Bot; + Username = model.Username; + } + + public override string ToString() => $"{Username}#{Discriminator}"; + private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id})"; + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedDMChannel.cs b/src/Discord.Net/Entities/WebSocket/CachedDMChannel.cs new file mode 100644 index 000000000..ed3eadac9 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedDMChannel.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using MessageModel = Discord.API.Message; +using Model = Discord.API.Channel; + +namespace Discord +{ + internal class CachedDMChannel : DMChannel, IDMChannel, ICachedChannel, ICachedMessageChannel + { + private readonly MessageManager _messages; + + public new DiscordSocketClient Discord => base.Discord as DiscordSocketClient; + public new CachedDMUser Recipient => base.Recipient as CachedDMUser; + public IReadOnlyCollection Members => ImmutableArray.Create(Discord.CurrentUser, Recipient); + + public CachedDMChannel(DiscordSocketClient discord, CachedDMUser recipient, Model model) + : base(discord, recipient, model) + { + if (Discord.MessageCacheSize > 0) + _messages = new MessageCache(Discord, this); + else + _messages = new MessageManager(Discord, this); + } + + public override Task GetUserAsync(ulong id) => Task.FromResult(GetUser(id)); + public override Task> GetUsersAsync() => Task.FromResult>(Members); + public override Task> GetUsersAsync(int limit, int offset) + => Task.FromResult>(Members.Skip(offset).Take(limit).ToImmutableArray()); + public ICachedUser GetUser(ulong id) + { + var currentUser = Discord.CurrentUser; + if (id == Recipient.Id) + return Recipient; + else if (id == currentUser.Id) + return currentUser; + else + return null; + } + + public override async Task GetMessageAsync(ulong id) + { + return await _messages.DownloadAsync(id).ConfigureAwait(false); + } + public override async Task> GetMessagesAsync(int limit) + { + return await _messages.DownloadAsync(null, Direction.Before, limit).ConfigureAwait(false); + } + public override async Task> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit) + { + return await _messages.DownloadAsync(fromMessageId, dir, limit).ConfigureAwait(false); + } + public CachedMessage AddMessage(ICachedUser author, MessageModel model) + { + var msg = new CachedMessage(this, author, model); + _messages.Add(msg); + return msg; + } + public CachedMessage GetMessage(ulong id) + { + return _messages.Get(id); + } + public CachedMessage RemoveMessage(ulong id) + { + return _messages.Remove(id); + } + + public CachedDMChannel Clone() => MemberwiseClone() as CachedDMChannel; + + IMessage IMessageChannel.GetCachedMessage(ulong id) => GetMessage(id); + ICachedUser ICachedMessageChannel.GetUser(ulong id, bool skipCheck) => GetUser(id); + ICachedChannel ICachedChannel.Clone() => Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedDMUser.cs b/src/Discord.Net/Entities/WebSocket/CachedDMUser.cs new file mode 100644 index 000000000..cddd91c4f --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedDMUser.cs @@ -0,0 +1,40 @@ +using System; +using PresenceModel = Discord.API.Presence; + +namespace Discord +{ + internal class CachedDMUser : ICachedUser + { + public CachedGlobalUser User { get; } + + public Game Game { get; private set; } + public UserStatus Status { get; private set; } + + public DiscordSocketClient Discord => User.Discord; + + public ulong Id => User.Id; + public string AvatarUrl => User.AvatarUrl; + public DateTimeOffset CreatedAt => User.CreatedAt; + public string Discriminator => User.Discriminator; + public ushort DiscriminatorValue => User.DiscriminatorValue; + public bool IsAttached => User.IsAttached; + public bool IsBot => User.IsBot; + public string Mention => User.Mention; + public string NicknameMention => User.NicknameMention; + public string Username => User.Username; + + public CachedDMUser(CachedGlobalUser user) + { + User = user; + } + + public void Update(PresenceModel model, UpdateSource source) + { + Status = model.Status; + Game = model.Game != null ? new Game(model.Game) : null; + } + + public CachedDMUser Clone() => MemberwiseClone() as CachedDMUser; + ICachedUser ICachedUser.Clone() => Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedGlobalUser.cs b/src/Discord.Net/Entities/WebSocket/CachedGlobalUser.cs new file mode 100644 index 000000000..6d101870e --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedGlobalUser.cs @@ -0,0 +1,46 @@ +using System; +using Discord.API; +using Model = Discord.API.User; + +namespace Discord +{ + internal class CachedGlobalUser : User, ICachedUser + { + private ushort _references; + + public new DiscordSocketClient Discord { get { throw new NotSupportedException(); } } + public override UserStatus Status => UserStatus.Unknown;// _status; + public override Game Game => null; //_game; + + public CachedGlobalUser(Model model) + : base(model) + { + } + + public void AddRef() + { + checked + { + lock (this) + _references++; + } + } + public void RemoveRef(DiscordSocketClient discord) + { + lock (this) + { + if (--_references == 0) + discord.RemoveUser(Id); + } + } + + public override void Update(Model model, UpdateSource source) + { + lock (this) + base.Update(model, source); + } + + public CachedGlobalUser Clone() => MemberwiseClone() as CachedGlobalUser; + ICachedUser ICachedUser.Clone() => Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedGuild.cs b/src/Discord.Net/Entities/WebSocket/CachedGuild.cs new file mode 100644 index 000000000..d4c5153b4 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedGuild.cs @@ -0,0 +1,332 @@ +using Discord.Audio; +using Discord.Extensions; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ChannelModel = Discord.API.Channel; +using EmojiUpdateModel = Discord.API.Gateway.GuildEmojiUpdateEvent; +using ExtendedModel = Discord.API.Gateway.ExtendedGuild; +using GuildSyncModel = Discord.API.Gateway.GuildSyncEvent; +using MemberModel = Discord.API.GuildMember; +using Model = Discord.API.Guild; +using PresenceModel = Discord.API.Presence; +using RoleModel = Discord.API.Role; +using VoiceStateModel = Discord.API.VoiceState; + +namespace Discord +{ + internal class CachedGuild : Guild, ICachedEntity, IGuild, IUserGuild + { + private readonly SemaphoreSlim _audioLock; + private TaskCompletionSource _syncPromise, _downloaderPromise; + private ConcurrentHashSet _channels; + private ConcurrentDictionary _members; + private ConcurrentDictionary _voiceStates; + + public bool Available { get; private set; } + public int MemberCount { get; private set; } + public int DownloadedMemberCount { get; private set; } + public AudioClient AudioClient { get; private set; } + + public bool HasAllMembers => _downloaderPromise.Task.IsCompleted; + public Task SyncPromise => _syncPromise.Task; + public Task DownloaderPromise => _downloaderPromise.Task; + + public new DiscordSocketClient Discord => base.Discord as DiscordSocketClient; + public CachedGuildUser CurrentUser => GetUser(Discord.CurrentUser.Id); + public IReadOnlyCollection Channels + { + get + { + var channels = _channels; + var store = Discord.DataStore; + return channels.Select(x => store.GetChannel(x) as ICachedGuildChannel).Where(x => x != null).ToReadOnlyCollection(channels); + } + } + public IReadOnlyCollection Members => _members.ToReadOnlyCollection(); + public IEnumerable> VoiceStates => _voiceStates; + + public CachedGuild(DiscordSocketClient discord, ExtendedModel model, DataStore dataStore) : base(discord, model) + { + _audioLock = new SemaphoreSlim(1, 1); + _syncPromise = new TaskCompletionSource(); + _downloaderPromise = new TaskCompletionSource(); + Update(model, UpdateSource.Creation, dataStore); + } + + public void Update(ExtendedModel model, UpdateSource source, DataStore dataStore) + { + if (source == UpdateSource.Rest && IsAttached) return; + + Available = !(model.Unavailable ?? false); + if (!Available) + { + if (_channels == null) + _channels = new ConcurrentHashSet(); + if (_members == null) + _members = new ConcurrentDictionary(); + if (_roles == null) + _roles = new ConcurrentDictionary(); + if (Emojis == null) + Emojis = ImmutableArray.Create(); + if (Features == null) + Features = ImmutableArray.Create(); + return; + } + + base.Update(model as Model, source); + + MemberCount = model.MemberCount; + + var channels = new ConcurrentHashSet(1, (int)(model.Channels.Length * 1.05)); + { + for (int i = 0; i < model.Channels.Length; i++) + AddChannel(model.Channels[i], dataStore, channels); + } + _channels = channels; + + var members = new ConcurrentDictionary(1, (int)(model.Presences.Length * 1.05)); + { + DownloadedMemberCount = 0; + for (int i = 0; i < model.Members.Length; i++) + AddUser(model.Members[i], dataStore, members); + if (Discord.ApiClient.AuthTokenType != TokenType.User) + { + _syncPromise.TrySetResult(true); + if (!model.Large) + _downloaderPromise.TrySetResult(true); + } + + for (int i = 0; i < model.Presences.Length; i++) + AddOrUpdateUser(model.Presences[i], dataStore, members); + } + _members = members; + + var voiceStates = new ConcurrentDictionary(1, (int)(model.VoiceStates.Length * 1.05)); + { + for (int i = 0; i < model.VoiceStates.Length; i++) + AddOrUpdateVoiceState(model.VoiceStates[i], dataStore, voiceStates); + } + _voiceStates = voiceStates; + } + public void Update(GuildSyncModel model, UpdateSource source, DataStore dataStore) + { + if (source == UpdateSource.Rest && IsAttached) return; + + var members = new ConcurrentDictionary(1, (int)(model.Presences.Length * 1.05)); + { + DownloadedMemberCount = 0; + for (int i = 0; i < model.Members.Length; i++) + AddUser(model.Members[i], dataStore, members); + _syncPromise.TrySetResult(true); + if (!model.Large) + _downloaderPromise.TrySetResult(true); + + for (int i = 0; i < model.Presences.Length; i++) + AddOrUpdateUser(model.Presences[i], dataStore, members); + } + _members = members; + } + + public void Update(EmojiUpdateModel model, UpdateSource source) + { + if (source == UpdateSource.Rest && IsAttached) return; + + var emojis = ImmutableArray.CreateBuilder(model.Emojis.Length); + for (int i = 0; i < model.Emojis.Length; i++) + emojis.Add(new Emoji(model.Emojis[i])); + Emojis = emojis.ToImmutableArray(); + } + + public override Task GetChannelAsync(ulong id) => Task.FromResult(GetChannel(id)); + public override Task> GetChannelsAsync() => Task.FromResult>(Channels); + public void AddChannel(ChannelModel model, DataStore dataStore, ConcurrentHashSet channels = null) + { + var channel = ToChannel(model); + (channels ?? _channels).TryAdd(model.Id); + dataStore.AddChannel(channel); + } + public ICachedGuildChannel GetChannel(ulong id) + { + return Discord.DataStore.GetChannel(id) as ICachedGuildChannel; + } + public ICachedGuildChannel RemoveChannel(ulong id) + { + _channels.TryRemove(id); + return Discord.DataStore.RemoveChannel(id) as ICachedGuildChannel; + } + + public Role AddRole(RoleModel model, ConcurrentDictionary roles = null) + { + var role = new Role(this, model); + (roles ?? _roles)[model.Id] = role; + return role; + } + public Role RemoveRole(ulong id) + { + Role role; + if (_roles.TryRemove(id, out role)) + return role; + return null; + } + + public override Task GetUserAsync(ulong id) => Task.FromResult(GetUser(id)); + public override Task GetCurrentUserAsync() + => Task.FromResult(CurrentUser); + public override Task> GetUsersAsync() + => Task.FromResult>(Members); + //TODO: Is there a better way of exposing pagination? + public override Task> GetUsersAsync(int limit, int offset) + => Task.FromResult>(Members.OrderBy(x => x.Id).Skip(offset).Take(limit).ToImmutableArray()); + public CachedGuildUser AddUser(MemberModel model, DataStore dataStore, ConcurrentDictionary members = null) + { + members = members ?? _members; + + CachedGuildUser member; + if (members.TryGetValue(model.User.Id, out member)) + member.Update(model, UpdateSource.WebSocket); + else + { + var user = Discord.GetOrAddUser(model.User, dataStore); + member = new CachedGuildUser(this, user, model); + members[user.Id] = member; + user.AddRef(); + DownloadedMemberCount++; + } + return member; + } + public CachedGuildUser AddOrUpdateUser(PresenceModel model, DataStore dataStore, ConcurrentDictionary members = null) + { + members = members ?? _members; + + CachedGuildUser member; + if (members.TryGetValue(model.User.Id, out member)) + member.Update(model, UpdateSource.WebSocket); + else + { + var user = Discord.GetOrAddUser(model.User, dataStore); + member = new CachedGuildUser(this, user, model); + members[user.Id] = member; + user.AddRef(); + DownloadedMemberCount++; + } + return member; + } + public CachedGuildUser GetUser(ulong id) + { + CachedGuildUser member; + if (_members.TryGetValue(id, out member)) + return member; + return null; + } + public CachedGuildUser RemoveUser(ulong id) + { + CachedGuildUser member; + if (_members.TryRemove(id, out member)) + return member; + return null; + } + public override async Task DownloadUsersAsync() + { + await Discord.DownloadUsersAsync(new [] { this }); + } + public void CompleteDownloadMembers() + { + _downloaderPromise.TrySetResult(true); + } + + public VoiceState AddOrUpdateVoiceState(VoiceStateModel model, DataStore dataStore, ConcurrentDictionary voiceStates = null) + { + var voiceChannel = dataStore.GetChannel(model.ChannelId.Value) as CachedVoiceChannel; + var voiceState = new VoiceState(voiceChannel, model); + (voiceStates ?? _voiceStates)[model.UserId] = voiceState; + return voiceState; + } + public VoiceState? GetVoiceState(ulong id) + { + VoiceState voiceState; + if (_voiceStates.TryGetValue(id, out voiceState)) + return voiceState; + return null; + } + public VoiceState? RemoveVoiceState(ulong id) + { + VoiceState voiceState; + if (_voiceStates.TryRemove(id, out voiceState)) + return voiceState; + return null; + } + + public async Task ConnectAudio(int id, string url, string token) + { + AudioClient audioClient; + await _audioLock.WaitAsync().ConfigureAwait(false); + var voiceState = GetVoiceState(CurrentUser.Id).Value; + try + { + audioClient = AudioClient; + if (audioClient == null) + { + audioClient = new AudioClient(this, id); + audioClient.Disconnected += async ex => + { + await _audioLock.WaitAsync().ConfigureAwait(false); + try + { + if (ex != null) + { + //Reconnect if we still have channel info. + //TODO: Is this threadsafe? Could channel data be deleted before we access it? + var voiceState2 = GetVoiceState(CurrentUser.Id); + if (voiceState2.HasValue) + { + var voiceChannelId = voiceState2.Value.VoiceChannel?.Id; + if (voiceChannelId != null) + await Discord.ApiClient.SendVoiceStateUpdateAsync(Id, voiceChannelId, voiceState2.Value.IsSelfDeafened, voiceState2.Value.IsSelfMuted); + } + } + else + { + try { AudioClient.Dispose(); } catch { } + AudioClient = null; + } + } + finally + { + _audioLock.Release(); + } + }; + AudioClient = audioClient; + } + } + finally + { + _audioLock.Release(); + } + await audioClient.ConnectAsync(url, CurrentUser.Id, voiceState.VoiceSessionId, token).ConfigureAwait(false); + } + + public CachedGuild Clone() => MemberwiseClone() as CachedGuild; + + new internal ICachedGuildChannel ToChannel(ChannelModel model) + { + switch (model.Type.Value) + { + case ChannelType.Text: + return new CachedTextChannel(this, model); + case ChannelType.Voice: + return new CachedVoiceChannel(this, model); + default: + throw new InvalidOperationException($"Unknown channel type: {model.Type.Value}"); + } + } + + bool IUserGuild.IsOwner => OwnerId == Discord.CurrentUser.Id; + GuildPermissions IUserGuild.Permissions => CurrentUser.GuildPermissions; + IAudioClient IGuild.AudioClient => AudioClient; + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedGuildUser.cs b/src/Discord.Net/Entities/WebSocket/CachedGuildUser.cs new file mode 100644 index 000000000..0b84d227b --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedGuildUser.cs @@ -0,0 +1,59 @@ +using Model = Discord.API.GuildMember; +using PresenceModel = Discord.API.Presence; + +namespace Discord +{ + //TODO: C#7 Candidate for record type + internal struct Presence : IPresence + { + public Game Game { get; } + public UserStatus Status { get; } + + public Presence(Game game, UserStatus status) + { + Game = game; + Status = status; + } + + public Presence Clone() => this; + } + + internal class CachedGuildUser : GuildUser, ICachedUser + { + public Presence Presence { get; private set; } + + public new DiscordSocketClient Discord => base.Discord as DiscordSocketClient; + public new CachedGuild Guild => base.Guild as CachedGuild; + public new CachedGlobalUser User => base.User as CachedGlobalUser; + + public override Game Game => Presence.Game; + public override UserStatus Status => Presence.Status; + + public VoiceState? VoiceState => Guild.GetVoiceState(Id); + public bool IsSelfDeafened => VoiceState?.IsSelfDeafened ?? false; + public bool IsSelfMuted => VoiceState?.IsSelfMuted ?? false; + public bool IsSuppressed => VoiceState?.IsSuppressed ?? false; + public CachedVoiceChannel VoiceChannel => VoiceState?.VoiceChannel; + + public CachedGuildUser(CachedGuild guild, CachedGlobalUser user, Model model) + : base(guild, user, model) + { + Presence = new Presence(null, UserStatus.Offline); + } + public CachedGuildUser(CachedGuild guild, CachedGlobalUser user, PresenceModel model) + : base(guild, user, model) + { + } + + public override void Update(PresenceModel model, UpdateSource source) + { + base.Update(model, source); + + var game = model.Game != null ? new Game(model.Game) : null; + Presence = new Presence(game, model.Status); + } + + public CachedGuildUser Clone() => MemberwiseClone() as CachedGuildUser; + ICachedUser ICachedUser.Clone() => Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedMessage.cs b/src/Discord.Net/Entities/WebSocket/CachedMessage.cs new file mode 100644 index 000000000..72edb107d --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedMessage.cs @@ -0,0 +1,17 @@ +using Model = Discord.API.Message; + +namespace Discord +{ + internal class CachedMessage : Message, ICachedEntity + { + public new DiscordSocketClient Discord => base.Discord as DiscordSocketClient; + public new ICachedMessageChannel Channel => base.Channel as ICachedMessageChannel; + + public CachedMessage(ICachedMessageChannel channel, IUser author, Model model) + : base(channel, author, model) + { + } + + public CachedMessage Clone() => MemberwiseClone() as CachedMessage; + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedSelfUser.cs b/src/Discord.Net/Entities/WebSocket/CachedSelfUser.cs new file mode 100644 index 000000000..9b3543c11 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedSelfUser.cs @@ -0,0 +1,17 @@ +using Model = Discord.API.User; + +namespace Discord +{ + internal class CachedSelfUser : SelfUser, ICachedUser + { + public new DiscordSocketClient Discord => base.Discord as DiscordSocketClient; + + public CachedSelfUser(DiscordSocketClient discord, Model model) + : base(discord, model) + { + } + + public CachedSelfUser Clone() => MemberwiseClone() as CachedSelfUser; + ICachedUser ICachedUser.Clone() => Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedTextChannel.cs b/src/Discord.Net/Entities/WebSocket/CachedTextChannel.cs new file mode 100644 index 000000000..7a91a8221 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedTextChannel.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using MessageModel = Discord.API.Message; +using Model = Discord.API.Channel; + +namespace Discord +{ + internal class CachedTextChannel : TextChannel, ICachedGuildChannel, ICachedMessageChannel + { + private readonly MessageManager _messages; + + public new DiscordSocketClient Discord => base.Discord as DiscordSocketClient; + public new CachedGuild Guild => base.Guild as CachedGuild; + + public IReadOnlyCollection Members + => Guild.Members.Where(x => Permissions.GetValue(Permissions.ResolveChannel(x, this, x.GuildPermissions.RawValue), ChannelPermission.ReadMessages)).ToImmutableArray(); + + public CachedTextChannel(CachedGuild guild, Model model) + : base(guild, model) + { + if (Discord.MessageCacheSize > 0) + _messages = new MessageCache(Discord, this); + else + _messages = new MessageManager(Discord, this); + } + + public override Task GetUserAsync(ulong id) => Task.FromResult(GetUser(id)); + public override Task> GetUsersAsync() => Task.FromResult>(Members); + public override Task> GetUsersAsync(int limit, int offset) + => Task.FromResult>(Members.Skip(offset).Take(limit).ToImmutableArray()); + public CachedGuildUser GetUser(ulong id, bool skipCheck = false) + { + var user = Guild.GetUser(id); + if (skipCheck) return user; + + if (user != null) + { + ulong perms = Permissions.ResolveChannel(user, this, user.GuildPermissions.RawValue); + if (Permissions.GetValue(perms, ChannelPermission.ReadMessages)) + return user; + } + return null; + } + + public override async Task GetMessageAsync(ulong id) + { + return await _messages.DownloadAsync(id).ConfigureAwait(false); + } + public override async Task> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch) + { + return await _messages.DownloadAsync(null, Direction.Before, limit).ConfigureAwait(false); + } + public override async Task> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + { + return await _messages.DownloadAsync(fromMessageId, dir, limit).ConfigureAwait(false); + } + + public CachedMessage AddMessage(ICachedUser author, MessageModel model) + { + var msg = new CachedMessage(this, author, model); + _messages.Add(msg); + return msg; + } + public CachedMessage GetMessage(ulong id) + { + return _messages.Get(id); + } + public CachedMessage RemoveMessage(ulong id) + { + return _messages.Remove(id); + } + + public CachedTextChannel Clone() => MemberwiseClone() as CachedTextChannel; + + IReadOnlyCollection ICachedMessageChannel.Members => Members; + + IMessage IMessageChannel.GetCachedMessage(ulong id) => GetMessage(id); + ICachedUser ICachedMessageChannel.GetUser(ulong id, bool skipCheck) => GetUser(id, skipCheck); + ICachedChannel ICachedChannel.Clone() => Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/CachedVoiceChannel.cs b/src/Discord.Net/Entities/WebSocket/CachedVoiceChannel.cs new file mode 100644 index 000000000..358464188 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/CachedVoiceChannel.cs @@ -0,0 +1,55 @@ +using Discord.Audio; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord +{ + internal class CachedVoiceChannel : VoiceChannel, ICachedGuildChannel + { + public new DiscordSocketClient Discord => base.Discord as DiscordSocketClient; + public new CachedGuild Guild => base.Guild as CachedGuild; + + public IReadOnlyCollection Members + => Guild.VoiceStates.Where(x => x.Value.VoiceChannel.Id == Id).Select(x => Guild.GetUser(x.Key)).ToImmutableArray(); + + public CachedVoiceChannel(CachedGuild guild, Model model) + : base(guild, model) + { + } + + public override Task GetUserAsync(ulong id) + => Task.FromResult(GetUser(id)); + public override Task> GetUsersAsync() + => Task.FromResult(Members); + public override Task> GetUsersAsync(int limit, int offset) + => Task.FromResult>(Members.OrderBy(x => x.Id).Skip(offset).Take(limit).ToImmutableArray()); + public IGuildUser GetUser(ulong id) + { + var user = Guild.GetUser(id); + if (user != null && user.VoiceChannel.Id == Id) + return user; + return null; + } + + public override async Task ConnectAsync() + { + var audioMode = Discord.AudioMode; + if (audioMode == AudioMode.Disabled) + throw new InvalidOperationException($"Audio is not enabled on this client, {nameof(DiscordSocketConfig.AudioMode)} in {nameof(DiscordSocketConfig)} must be set."); + + await Discord.ApiClient.SendVoiceStateUpdateAsync(Guild.Id, Id, + (audioMode & AudioMode.Incoming) == 0, + (audioMode & AudioMode.Outgoing) == 0).ConfigureAwait(false); + return null; + //TODO: Block and return + } + + public CachedVoiceChannel Clone() => MemberwiseClone() as CachedVoiceChannel; + + ICachedChannel ICachedChannel.Clone() => Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/ICachedChannel.cs b/src/Discord.Net/Entities/WebSocket/ICachedChannel.cs new file mode 100644 index 000000000..caebf7c10 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/ICachedChannel.cs @@ -0,0 +1,11 @@ +using Model = Discord.API.Channel; + +namespace Discord +{ + internal interface ICachedChannel : IChannel, ICachedEntity + { + void Update(Model model, UpdateSource source); + + ICachedChannel Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/ICachedEntity.cs b/src/Discord.Net/Entities/WebSocket/ICachedEntity.cs new file mode 100644 index 000000000..48dc26f2e --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/ICachedEntity.cs @@ -0,0 +1,7 @@ +namespace Discord +{ + internal interface ICachedEntity : IEntity + { + DiscordSocketClient Discord { get; } + } +} diff --git a/src/Discord.Net/Entities/WebSocket/ICachedGuildChannel.cs b/src/Discord.Net/Entities/WebSocket/ICachedGuildChannel.cs new file mode 100644 index 000000000..290bff64e --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/ICachedGuildChannel.cs @@ -0,0 +1,7 @@ +namespace Discord +{ + internal interface ICachedGuildChannel : ICachedChannel, IGuildChannel + { + new CachedGuild Guild { get; } + } +} diff --git a/src/Discord.Net/Entities/WebSocket/ICachedMessageChannel.cs b/src/Discord.Net/Entities/WebSocket/ICachedMessageChannel.cs new file mode 100644 index 000000000..9704198b0 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/ICachedMessageChannel.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using MessageModel = Discord.API.Message; + +namespace Discord +{ + internal interface ICachedMessageChannel : ICachedChannel, IMessageChannel + { + IReadOnlyCollection Members { get; } + + CachedMessage AddMessage(ICachedUser author, MessageModel model); + CachedMessage GetMessage(ulong id); + CachedMessage RemoveMessage(ulong id); + + ICachedUser GetUser(ulong id, bool skipCheck = false); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/ICachedUser.cs b/src/Discord.Net/Entities/WebSocket/ICachedUser.cs new file mode 100644 index 000000000..e9e7d2929 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/ICachedUser.cs @@ -0,0 +1,7 @@ +namespace Discord +{ + internal interface ICachedUser : IUser, ICachedEntity + { + ICachedUser Clone(); + } +} diff --git a/src/Discord.Net/Entities/WebSocket/MessageCache.cs b/src/Discord.Net/Entities/WebSocket/MessageCache.cs new file mode 100644 index 000000000..0eaee13c3 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/MessageCache.cs @@ -0,0 +1,88 @@ +using Discord.Extensions; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord +{ + internal class MessageCache : MessageManager + { + private readonly ConcurrentDictionary _messages; + private readonly ConcurrentQueue _orderedMessages; + private readonly int _size; + + public override IReadOnlyCollection Messages => _messages.ToReadOnlyCollection(); + + public MessageCache(DiscordSocketClient discord, ICachedMessageChannel channel) + : base(discord, channel) + { + _size = discord.MessageCacheSize; + _messages = new ConcurrentDictionary(1, (int)(_size * 1.05)); + _orderedMessages = new ConcurrentQueue(); + } + + public override void Add(CachedMessage message) + { + if (_messages.TryAdd(message.Id, message)) + { + _orderedMessages.Enqueue(message.Id); + + ulong msgId; + CachedMessage msg; + while (_orderedMessages.Count > _size && _orderedMessages.TryDequeue(out msgId)) + _messages.TryRemove(msgId, out msg); + } + } + + public override CachedMessage Remove(ulong id) + { + CachedMessage msg; + _messages.TryRemove(id, out msg); + return msg; + } + + public override CachedMessage Get(ulong id) + { + CachedMessage result; + if (_messages.TryGetValue(id, out result)) + return result; + return null; + } + public override IImmutableList GetMany(ulong? fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + { + if (limit < 0) throw new ArgumentOutOfRangeException(nameof(limit)); + if (limit == 0) return ImmutableArray.Empty; + + IEnumerable cachedMessageIds; + if (fromMessageId == null) + cachedMessageIds = _orderedMessages; + else if (dir == Direction.Before) + cachedMessageIds = _orderedMessages.Where(x => x < fromMessageId.Value); + else + cachedMessageIds = _orderedMessages.Where(x => x > fromMessageId.Value); + + return cachedMessageIds + .Take(limit) + .Select(x => + { + CachedMessage msg; + if (_messages.TryGetValue(x, out msg)) + return msg; + return null; + }) + .Where(x => x != null) + .ToImmutableArray(); + } + + public override async Task DownloadAsync(ulong id) + { + var msg = Get(id); + if (msg != null) + return msg; + return await base.DownloadAsync(id).ConfigureAwait(false); + } + } +} diff --git a/src/Discord.Net/Entities/WebSocket/MessageManager.cs b/src/Discord.Net/Entities/WebSocket/MessageManager.cs new file mode 100644 index 000000000..98fde21b0 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/MessageManager.cs @@ -0,0 +1,81 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord +{ + internal class MessageManager + { + private readonly DiscordSocketClient _discord; + private readonly ICachedMessageChannel _channel; + + public virtual IReadOnlyCollection Messages + => ImmutableArray.Create(); + + public MessageManager(DiscordSocketClient discord, ICachedMessageChannel channel) + { + _discord = discord; + _channel = channel; + } + + public virtual void Add(CachedMessage message) { } + public virtual CachedMessage Remove(ulong id) => null; + public virtual CachedMessage Get(ulong id) => null; + + public virtual IImmutableList GetMany(ulong? fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + => ImmutableArray.Create(); + + public virtual async Task DownloadAsync(ulong id) + { + var model = await _discord.ApiClient.GetChannelMessageAsync(_channel.Id, id).ConfigureAwait(false); + if (model != null) + return new CachedMessage(_channel, new User(model.Author.Value), model); + return null; + } + public async Task> DownloadAsync(ulong? fromId, Direction dir, int limit) + { + //TODO: Test heavily, especially the ordering of messages + if (limit < 0) throw new ArgumentOutOfRangeException(nameof(limit)); + if (limit == 0) return ImmutableArray.Empty; + + var cachedMessages = GetMany(fromId, dir, limit); + if (cachedMessages.Count == limit) + return cachedMessages; + else if (cachedMessages.Count > limit) + return cachedMessages.Skip(cachedMessages.Count - limit).ToImmutableArray(); + else + { + Optional relativeId; + if (cachedMessages.Count == 0) + relativeId = fromId ?? new Optional(); + else + relativeId = dir == Direction.Before ? cachedMessages[0].Id : cachedMessages[cachedMessages.Count - 1].Id; + var args = new GetChannelMessagesParams + { + Limit = limit - cachedMessages.Count, + RelativeDirection = dir, + RelativeMessageId = relativeId + }; + var downloadedMessages = await _discord.ApiClient.GetChannelMessagesAsync(_channel.Id, args).ConfigureAwait(false); + + var guild = (_channel as ICachedGuildChannel).Guild; + return cachedMessages.Concat(downloadedMessages.Select(x => + { + IUser user = _channel.GetUser(x.Author.Value.Id, true); + if (user == null) + { + var newUser = new User(x.Author.Value); + if (guild != null) + user = new GuildUser(guild, newUser); + else + user = newUser; + } + return new CachedMessage(_channel, user, x); + })).ToImmutableArray(); + } + } + } +} diff --git a/src/Discord.Net/Entities/WebSocket/VoiceState.cs b/src/Discord.Net/Entities/WebSocket/VoiceState.cs new file mode 100644 index 000000000..275108476 --- /dev/null +++ b/src/Discord.Net/Entities/WebSocket/VoiceState.cs @@ -0,0 +1,52 @@ +using System; +using Model = Discord.API.VoiceState; + +namespace Discord +{ + //TODO: C#7 Candidate for record type + internal struct VoiceState : IVoiceState + { + [Flags] + private enum Flags : byte + { + None = 0x00, + Suppressed = 0x01, + Muted = 0x02, + Deafened = 0x04, + SelfMuted = 0x08, + SelfDeafened = 0x10, + } + + private readonly Flags _voiceStates; + + public CachedVoiceChannel VoiceChannel { get; } + public string VoiceSessionId { get; } + + public bool IsMuted => (_voiceStates & Flags.Muted) != 0; + public bool IsDeafened => (_voiceStates & Flags.Deafened) != 0; + public bool IsSuppressed => (_voiceStates & Flags.Suppressed) != 0; + public bool IsSelfMuted => (_voiceStates & Flags.SelfMuted) != 0; + public bool IsSelfDeafened => (_voiceStates & Flags.SelfDeafened) != 0; + + public VoiceState(CachedVoiceChannel voiceChannel, Model model) + : this(voiceChannel, model.SessionId, model.SelfMute, model.SelfDeaf, model.Suppress) { } + public VoiceState(CachedVoiceChannel voiceChannel, string sessionId, bool isSelfMuted, bool isSelfDeafened, bool isSuppressed) + { + VoiceChannel = voiceChannel; + VoiceSessionId = sessionId; + + Flags voiceStates = Flags.None; + if (isSelfMuted) + voiceStates |= Flags.SelfMuted; + if (isSelfDeafened) + voiceStates |= Flags.SelfDeafened; + if (isSuppressed) + voiceStates |= Flags.Suppressed; + _voiceStates = voiceStates; + } + + public VoiceState Clone() => this; + + IVoiceChannel IVoiceState.VoiceChannel => VoiceChannel; + } +} diff --git a/src/Discord.Net/EventExtensions.cs b/src/Discord.Net/EventExtensions.cs deleted file mode 100644 index b46cb9056..000000000 --- a/src/Discord.Net/EventExtensions.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace Discord -{ - internal static class EventExtensions - { - //TODO: Optimize these for if there is only 1 subscriber (can we do this?) - public static async Task Raise(this Func eventHandler) - { - var subscriptions = eventHandler?.GetInvocationList(); - if (subscriptions != null) - { - for (int i = 0; i < subscriptions.Length; i++) - await (subscriptions[i] as Func).Invoke().ConfigureAwait(false); - } - } - public static async Task Raise(this Func eventHandler, T arg) - { - var subscriptions = eventHandler?.GetInvocationList(); - if (subscriptions != null) - { - for (int i = 0; i < subscriptions.Length; i++) - await (subscriptions[i] as Func).Invoke(arg).ConfigureAwait(false); - } - } - public static async Task Raise(this Func eventHandler, T1 arg1, T2 arg2) - { - var subscriptions = eventHandler?.GetInvocationList(); - if (subscriptions != null) - { - for (int i = 0; i < subscriptions.Length; i++) - await (subscriptions[i] as Func).Invoke(arg1, arg2).ConfigureAwait(false); - } - } - public static async Task Raise(this Func eventHandler, T1 arg1, T2 arg2, T3 arg3) - { - var subscriptions = eventHandler?.GetInvocationList(); - if (subscriptions != null) - { - for (int i = 0; i < subscriptions.Length; i++) - await (subscriptions[i] as Func).Invoke(arg1, arg2, arg3).ConfigureAwait(false); - } - } - } -} diff --git a/src/Discord.Net/Extensions/CollectionExtensions.cs b/src/Discord.Net/Extensions/CollectionExtensions.cs new file mode 100644 index 000000000..921379bfc --- /dev/null +++ b/src/Discord.Net/Extensions/CollectionExtensions.cs @@ -0,0 +1,36 @@ +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Discord.Extensions +{ + internal static class CollectionExtensions + { + public static IReadOnlyCollection ToReadOnlyCollection(this IReadOnlyDictionary source) + => new ConcurrentDictionaryWrapper>(source, source.Select(x => x.Value)); + public static IReadOnlyCollection ToReadOnlyCollection(this IEnumerable query, IReadOnlyCollection source) + => new ConcurrentDictionaryWrapper(source, query); + } + + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal struct ConcurrentDictionaryWrapper : IReadOnlyCollection + { + private readonly IReadOnlyCollection _source; + private readonly IEnumerable _query; + + //It's okay that this count is affected by race conditions - we're wrapping a concurrent collection and that's to be expected + public int Count => _source.Count; + + public ConcurrentDictionaryWrapper(IReadOnlyCollection source, IEnumerable query) + { + _source = source; + _query = query; + } + + private string DebuggerDisplay => $"Count = {Count}"; + + public IEnumerator GetEnumerator() => _query.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _query.GetEnumerator(); + } +} diff --git a/src/Discord.Net/Extensions/DiscordClientExtensions.cs b/src/Discord.Net/Extensions/DiscordClientExtensions.cs new file mode 100644 index 000000000..53e09c0e0 --- /dev/null +++ b/src/Discord.Net/Extensions/DiscordClientExtensions.cs @@ -0,0 +1,14 @@ +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Extensions +{ + public static class DiscordClientExtensions + { + public static async Task GetOptimalVoiceRegionAsync(this DiscordClient discord) + { + var regions = await discord.GetVoiceRegionsAsync().ConfigureAwait(false); + return regions.FirstOrDefault(x => x.IsOptimal); + } + } +} diff --git a/src/Discord.Net/Extensions/GuildExtensions.cs b/src/Discord.Net/Extensions/GuildExtensions.cs new file mode 100644 index 000000000..e8895f22c --- /dev/null +++ b/src/Discord.Net/Extensions/GuildExtensions.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; + +namespace Discord.Extensions +{ + public static class GuildExtensions + { + public static async Task GetTextChannelAsync(this IGuild guild, ulong id) + => await guild.GetChannelAsync(id).ConfigureAwait(false) as ITextChannel; + public static async Task GetVoiceChannelAsync(this IGuild guild, ulong id) + => await guild.GetChannelAsync(id).ConfigureAwait(false) as IVoiceChannel; + + public static async Task GetAFKChannelAsync(this IGuild guild) + { + var afkId = guild.AFKChannelId; + if (afkId.HasValue) + return await guild.GetChannelAsync(afkId.Value).ConfigureAwait(false) as IVoiceChannel; + return null; + } + public static async Task GetDefaultChannelAsync(this IGuild guild) + => await guild.GetChannelAsync(guild.DefaultChannelId).ConfigureAwait(false) as ITextChannel; + public static async Task GetEmbedChannelAsync(this IGuild guild) + { + var embedId = guild.EmbedChannelId; + if (embedId.HasValue) + return await guild.GetChannelAsync(embedId.Value).ConfigureAwait(false) as IVoiceChannel; + return null; + } + public static async Task GetOwnerAsync(this IGuild guild) + => await guild.GetUserAsync(guild.OwnerId).ConfigureAwait(false); + } +} diff --git a/src/Discord.Net/Extensions/GuildUserExtensions.cs b/src/Discord.Net/Extensions/GuildUserExtensions.cs new file mode 100644 index 000000000..9575e66dc --- /dev/null +++ b/src/Discord.Net/Extensions/GuildUserExtensions.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Extensions +{ + public static class GuildUserExtensions + { + public static Task AddRolesAsync(this IGuildUser user, params IRole[] roles) + => AddRolesAsync(user, (IEnumerable)roles); + public static Task AddRolesAsync(this IGuildUser user, IEnumerable roles) + => user.ModifyAsync(x => x.Roles = Optional.Create(user.Roles.Concat(roles))); + + public static Task RemoveRolesAsync(this IGuildUser user, params IRole[] roles) + => RemoveRolesAsync(user, (IEnumerable)roles); + public static Task RemoveRolesAsync(this IGuildUser user, IEnumerable roles) + => user.ModifyAsync(x => x.Roles = Optional.Create(user.Roles.Except(roles))); + } +} diff --git a/src/Discord.Net/Format.cs b/src/Discord.Net/Format.cs new file mode 100644 index 000000000..8b1d06bf8 --- /dev/null +++ b/src/Discord.Net/Format.cs @@ -0,0 +1,23 @@ +namespace Discord +{ + public static class Format + { + /// Returns a markdown-formatted string with bold formatting. + public static string Bold(string text) => $"**{text}**"; + /// Returns a markdown-formatted string with italics formatting. + public static string Italics(string text) => $"*{text}*"; + /// Returns a markdown-formatted string with underline formatting. + public static string Underline(string text) => $"__{text}__"; + /// Returns a markdown-formatted string with strikethrough formatting. + public static string Strikethrough(string text) => $"~~{text}~~"; + + /// Returns a markdown-formatted string with strikeout formatting. + public static string Code(string text, string language = null) + { + if (language != null || text.Contains("\n")) + return $"```{language ?? ""}\n{text}\n```"; + else + return $"`{text}`"; + } + } +} diff --git a/src/Discord.Net/IDiscordClient.cs b/src/Discord.Net/IDiscordClient.cs index 21c3c477c..796eb2611 100644 --- a/src/Discord.Net/IDiscordClient.cs +++ b/src/Discord.Net/IDiscordClient.cs @@ -1,6 +1,6 @@ using Discord.API; -using Discord.Net.Queue; -using Discord.WebSocket.Data; +using Discord.Logging; +using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -8,39 +8,37 @@ using System.Threading.Tasks; namespace Discord { //TODO: Add docstrings - public interface IDiscordClient + public interface IDiscordClient : IDisposable { LoginState LoginState { get; } ConnectionState ConnectionState { get; } DiscordApiClient ApiClient { get; } - IRequestQueue RequestQueue { get; } - IDataStore DataStore { get; } - - Task Login(string email, string password); - Task Login(TokenType tokenType, string token, bool validateToken = true); - Task Logout(); + ILogManager LogManager { get; } + + Task LoginAsync(TokenType tokenType, string token, bool validateToken = true); + Task LogoutAsync(); - Task Connect(); - Task Disconnect(); + Task ConnectAsync(); + Task DisconnectAsync(); - Task GetChannel(ulong id); - Task> GetDMChannels(); + Task GetChannelAsync(ulong id); + Task> GetDMChannelsAsync(); - Task> GetConnections(); + Task> GetConnectionsAsync(); - Task GetGuild(ulong id); - Task> GetGuilds(); - Task CreateGuild(string name, IVoiceRegion region, Stream jpegIcon = null); + Task GetGuildAsync(ulong id); + Task> GetGuildsAsync(); + Task CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon = null); - Task GetInvite(string inviteIdOrXkcd); + Task GetInviteAsync(string inviteIdOrXkcd); - Task GetUser(ulong id); - Task GetUser(string username, ushort discriminator); - Task GetCurrentUser(); - Task> QueryUsers(string query, int limit); + Task GetUserAsync(ulong id); + Task GetUserAsync(string username, string discriminator); + Task GetCurrentUserAsync(); + Task> QueryUsersAsync(string query, int limit); - Task> GetVoiceRegions(); - Task GetVoiceRegion(string id); + Task> GetVoiceRegionsAsync(); + Task GetVoiceRegionAsync(string id); } } diff --git a/src/Discord.Net/Logging/ILogManager.cs b/src/Discord.Net/Logging/ILogManager.cs new file mode 100644 index 000000000..b244419b9 --- /dev/null +++ b/src/Discord.Net/Logging/ILogManager.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Logging +{ + public interface ILogManager + { + LogSeverity Level { get; } + + Task LogAsync(LogSeverity severity, string source, string message, Exception ex = null); + Task LogAsync(LogSeverity severity, string source, FormattableString message, Exception ex = null); + Task LogAsync(LogSeverity severity, string source, Exception ex); + + Task ErrorAsync(string source, string message, Exception ex = null); + Task ErrorAsync(string source, FormattableString message, Exception ex = null); + Task ErrorAsync(string source, Exception ex); + + Task WarningAsync(string source, string message, Exception ex = null); + Task WarningAsync(string source, FormattableString message, Exception ex = null); + Task WarningAsync(string source, Exception ex); + + Task InfoAsync(string source, string message, Exception ex = null); + Task InfoAsync(string source, FormattableString message, Exception ex = null); + Task InfoAsync(string source, Exception ex); + + Task VerboseAsync(string source, string message, Exception ex = null); + Task VerboseAsync(string source, FormattableString message, Exception ex = null); + Task VerboseAsync(string source, Exception ex); + + Task DebugAsync(string source, string message, Exception ex = null); + Task DebugAsync(string source, FormattableString message, Exception ex = null); + Task DebugAsync(string source, Exception ex); + + ILogger CreateLogger(string name); + } +} diff --git a/src/Discord.Net/Logging/ILogger.cs b/src/Discord.Net/Logging/ILogger.cs index ccc7f06f7..207c03dc7 100644 --- a/src/Discord.Net/Logging/ILogger.cs +++ b/src/Discord.Net/Logging/ILogger.cs @@ -7,28 +7,28 @@ namespace Discord.Logging { LogSeverity Level { get; } - Task Log(LogSeverity severity, string message, Exception exception = null); - Task Log(LogSeverity severity, FormattableString message, Exception exception = null); - Task Log(LogSeverity severity, Exception exception); + Task LogAsync(LogSeverity severity, string message, Exception exception = null); + Task LogAsync(LogSeverity severity, FormattableString message, Exception exception = null); + Task LogAsync(LogSeverity severity, Exception exception); - Task Error(string message, Exception exception = null); - Task Error(FormattableString message, Exception exception = null); - Task Error(Exception exception); + Task ErrorAsync(string message, Exception exception = null); + Task ErrorAsync(FormattableString message, Exception exception = null); + Task ErrorAsync(Exception exception); - Task Warning(string message, Exception exception = null); - Task Warning(FormattableString message, Exception exception = null); - Task Warning(Exception exception); + Task WarningAsync(string message, Exception exception = null); + Task WarningAsync(FormattableString message, Exception exception = null); + Task WarningAsync(Exception exception); - Task Info(string message, Exception exception = null); - Task Info(FormattableString message, Exception exception = null); - Task Info(Exception exception); + Task InfoAsync(string message, Exception exception = null); + Task InfoAsync(FormattableString message, Exception exception = null); + Task InfoAsync(Exception exception); - Task Verbose(string message, Exception exception = null); - Task Verbose(FormattableString message, Exception exception = null); - Task Verbose(Exception exception); + Task VerboseAsync(string message, Exception exception = null); + Task VerboseAsync(FormattableString message, Exception exception = null); + Task VerboseAsync(Exception exception); - Task Debug(string message, Exception exception = null); - Task Debug(FormattableString message, Exception exception = null); - Task Debug(Exception exception); + Task DebugAsync(string message, Exception exception = null); + Task DebugAsync(FormattableString message, Exception exception = null); + Task DebugAsync(Exception exception); } } diff --git a/src/Discord.Net/Logging/LogManager.cs b/src/Discord.Net/Logging/LogManager.cs index 5e5d819b7..e37b2bce6 100644 --- a/src/Discord.Net/Logging/LogManager.cs +++ b/src/Discord.Net/Logging/LogManager.cs @@ -3,113 +3,114 @@ using System.Threading.Tasks; namespace Discord.Logging { - internal class LogManager : ILogger + internal class LogManager : ILogManager, ILogger { public LogSeverity Level { get; } - public event Func Message; + public event Func Message { add { _messageEvent.Add(value); } remove { _messageEvent.Remove(value); } } + private readonly AsyncEvent> _messageEvent = new AsyncEvent>(); - internal LogManager(LogSeverity minSeverity) + public LogManager(LogSeverity minSeverity) { Level = minSeverity; } - public async Task Log(LogSeverity severity, string source, string message, Exception ex = null) + public async Task LogAsync(LogSeverity severity, string source, string message, Exception ex = null) { if (severity <= Level) - await Message.Raise(new LogMessage(severity, source, message, ex)).ConfigureAwait(false); + await _messageEvent.InvokeAsync(new LogMessage(severity, source, message, ex)).ConfigureAwait(false); } - public async Task Log(LogSeverity severity, string source, FormattableString message, Exception ex = null) + public async Task LogAsync(LogSeverity severity, string source, FormattableString message, Exception ex = null) { if (severity <= Level) - await Message.Raise(new LogMessage(severity, source, message.ToString(), ex)).ConfigureAwait(false); + await _messageEvent.InvokeAsync(new LogMessage(severity, source, message.ToString(), ex)).ConfigureAwait(false); } - public async Task Log(LogSeverity severity, string source, Exception ex) + public async Task LogAsync(LogSeverity severity, string source, Exception ex) { if (severity <= Level) - await Message.Raise(new LogMessage(severity, source, null, ex)).ConfigureAwait(false); + await _messageEvent.InvokeAsync(new LogMessage(severity, source, null, ex)).ConfigureAwait(false); } - async Task ILogger.Log(LogSeverity severity, string message, Exception ex) + async Task ILogger.LogAsync(LogSeverity severity, string message, Exception ex) { if (severity <= Level) - await Message.Raise(new LogMessage(severity, "Discord", message, ex)).ConfigureAwait(false); + await _messageEvent.InvokeAsync(new LogMessage(severity, "Discord", message, ex)).ConfigureAwait(false); } - async Task ILogger.Log(LogSeverity severity, FormattableString message, Exception ex) + async Task ILogger.LogAsync(LogSeverity severity, FormattableString message, Exception ex) { if (severity <= Level) - await Message.Raise(new LogMessage(severity, "Discord", message.ToString(), ex)).ConfigureAwait(false); + await _messageEvent.InvokeAsync(new LogMessage(severity, "Discord", message.ToString(), ex)).ConfigureAwait(false); } - async Task ILogger.Log(LogSeverity severity, Exception ex) + async Task ILogger.LogAsync(LogSeverity severity, Exception ex) { if (severity <= Level) - await Message.Raise(new LogMessage(severity, "Discord", null, ex)).ConfigureAwait(false); + await _messageEvent.InvokeAsync(new LogMessage(severity, "Discord", null, ex)).ConfigureAwait(false); } - public Task Error(string source, string message, Exception ex = null) - => Log(LogSeverity.Error, source, message, ex); - public Task Error(string source, FormattableString message, Exception ex = null) - => Log(LogSeverity.Error, source, message, ex); - public Task Error(string source, Exception ex) - => Log(LogSeverity.Error, source, ex); - Task ILogger.Error(string message, Exception ex) - => Log(LogSeverity.Error, "Discord", message, ex); - Task ILogger.Error(FormattableString message, Exception ex) - => Log(LogSeverity.Error, "Discord", message, ex); - Task ILogger.Error(Exception ex) - => Log(LogSeverity.Error, "Discord", ex); + public Task ErrorAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Error, source, message, ex); + public Task ErrorAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Error, source, message, ex); + public Task ErrorAsync(string source, Exception ex) + => LogAsync(LogSeverity.Error, source, ex); + Task ILogger.ErrorAsync(string message, Exception ex) + => LogAsync(LogSeverity.Error, "Discord", message, ex); + Task ILogger.ErrorAsync(FormattableString message, Exception ex) + => LogAsync(LogSeverity.Error, "Discord", message, ex); + Task ILogger.ErrorAsync(Exception ex) + => LogAsync(LogSeverity.Error, "Discord", ex); - public Task Warning(string source, string message, Exception ex = null) - => Log(LogSeverity.Warning, source, message, ex); - public Task Warning(string source, FormattableString message, Exception ex = null) - => Log(LogSeverity.Warning, source, message, ex); - public Task Warning(string source, Exception ex) - => Log(LogSeverity.Warning, source, ex); - Task ILogger.Warning(string message, Exception ex) - => Log(LogSeverity.Warning, "Discord", message, ex); - Task ILogger.Warning(FormattableString message, Exception ex) - => Log(LogSeverity.Warning, "Discord", message, ex); - Task ILogger.Warning(Exception ex) - => Log(LogSeverity.Warning, "Discord", ex); + public Task WarningAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Warning, source, message, ex); + public Task WarningAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Warning, source, message, ex); + public Task WarningAsync(string source, Exception ex) + => LogAsync(LogSeverity.Warning, source, ex); + Task ILogger.WarningAsync(string message, Exception ex) + => LogAsync(LogSeverity.Warning, "Discord", message, ex); + Task ILogger.WarningAsync(FormattableString message, Exception ex) + => LogAsync(LogSeverity.Warning, "Discord", message, ex); + Task ILogger.WarningAsync(Exception ex) + => LogAsync(LogSeverity.Warning, "Discord", ex); - public Task Info(string source, string message, Exception ex = null) - => Log(LogSeverity.Info, source, message, ex); - public Task Info(string source, FormattableString message, Exception ex = null) - => Log(LogSeverity.Info, source, message, ex); - public Task Info(string source, Exception ex) - => Log(LogSeverity.Info, source, ex); - Task ILogger.Info(string message, Exception ex) - => Log(LogSeverity.Info, "Discord", message, ex); - Task ILogger.Info(FormattableString message, Exception ex) - => Log(LogSeverity.Info, "Discord", message, ex); - Task ILogger.Info(Exception ex) - => Log(LogSeverity.Info, "Discord", ex); + public Task InfoAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Info, source, message, ex); + public Task InfoAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Info, source, message, ex); + public Task InfoAsync(string source, Exception ex) + => LogAsync(LogSeverity.Info, source, ex); + Task ILogger.InfoAsync(string message, Exception ex) + => LogAsync(LogSeverity.Info, "Discord", message, ex); + Task ILogger.InfoAsync(FormattableString message, Exception ex) + => LogAsync(LogSeverity.Info, "Discord", message, ex); + Task ILogger.InfoAsync(Exception ex) + => LogAsync(LogSeverity.Info, "Discord", ex); - public Task Verbose(string source, string message, Exception ex = null) - => Log(LogSeverity.Verbose, source, message, ex); - public Task Verbose(string source, FormattableString message, Exception ex = null) - => Log(LogSeverity.Verbose, source, message, ex); - public Task Verbose(string source, Exception ex) - => Log(LogSeverity.Verbose, source, ex); - Task ILogger.Verbose(string message, Exception ex) - => Log(LogSeverity.Verbose, "Discord", message, ex); - Task ILogger.Verbose(FormattableString message, Exception ex) - => Log(LogSeverity.Verbose, "Discord", message, ex); - Task ILogger.Verbose(Exception ex) - => Log(LogSeverity.Verbose, "Discord", ex); + public Task VerboseAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Verbose, source, message, ex); + public Task VerboseAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Verbose, source, message, ex); + public Task VerboseAsync(string source, Exception ex) + => LogAsync(LogSeverity.Verbose, source, ex); + Task ILogger.VerboseAsync(string message, Exception ex) + => LogAsync(LogSeverity.Verbose, "Discord", message, ex); + Task ILogger.VerboseAsync(FormattableString message, Exception ex) + => LogAsync(LogSeverity.Verbose, "Discord", message, ex); + Task ILogger.VerboseAsync(Exception ex) + => LogAsync(LogSeverity.Verbose, "Discord", ex); - public Task Debug(string source, string message, Exception ex = null) - => Log(LogSeverity.Debug, source, message, ex); - public Task Debug(string source, FormattableString message, Exception ex = null) - => Log(LogSeverity.Debug, source, message, ex); - public Task Debug(string source, Exception ex) - => Log(LogSeverity.Debug, source, ex); - Task ILogger.Debug(string message, Exception ex) - => Log(LogSeverity.Debug, "Discord", message, ex); - Task ILogger.Debug(FormattableString message, Exception ex) - => Log(LogSeverity.Debug, "Discord", message, ex); - Task ILogger.Debug(Exception ex) - => Log(LogSeverity.Debug, "Discord", ex); + public Task DebugAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Debug, source, message, ex); + public Task DebugAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Debug, source, message, ex); + public Task DebugAsync(string source, Exception ex) + => LogAsync(LogSeverity.Debug, source, ex); + Task ILogger.DebugAsync(string message, Exception ex) + => LogAsync(LogSeverity.Debug, "Discord", message, ex); + Task ILogger.DebugAsync(FormattableString message, Exception ex) + => LogAsync(LogSeverity.Debug, "Discord", message, ex); + Task ILogger.DebugAsync(Exception ex) + => LogAsync(LogSeverity.Debug, "Discord", ex); - internal Logger CreateLogger(string name) => new Logger(this, name); + public ILogger CreateLogger(string name) => new Logger(this, name); } } diff --git a/src/Discord.Net/Logging/Logger.cs b/src/Discord.Net/Logging/Logger.cs index 74435e012..2255f4451 100644 --- a/src/Discord.Net/Logging/Logger.cs +++ b/src/Discord.Net/Logging/Logger.cs @@ -3,57 +3,59 @@ using System.Threading.Tasks; namespace Discord.Logging { - internal class Logger + internal class Logger : ILogger { private readonly LogManager _manager; public string Name { get; } public LogSeverity Level => _manager.Level; - internal Logger(LogManager manager, string name) + public Logger(LogManager manager, string name) { _manager = manager; Name = name; } - public Task Log(LogSeverity severity, string message, Exception exception = null) - => _manager.Log(severity, Name, message, exception); - public Task Log(LogSeverity severity, FormattableString message, Exception exception = null) - => _manager.Log(severity, Name, message, exception); - - public Task Error(string message, Exception exception = null) - => _manager.Error(Name, message, exception); - public Task Error(FormattableString message, Exception exception = null) - => _manager.Error(Name, message, exception); - public Task Error(Exception exception) - => _manager.Error(Name, exception); - - public Task Warning(string message, Exception exception = null) - => _manager.Warning(Name, message, exception); - public Task Warning(FormattableString message, Exception exception = null) - => _manager.Warning(Name, message, exception); - public Task Warning(Exception exception) - => _manager.Warning(Name, exception); - - public Task Info(string message, Exception exception = null) - => _manager.Info(Name, message, exception); - public Task Info(FormattableString message, Exception exception = null) - => _manager.Info(Name, message, exception); - public Task Info(Exception exception) - => _manager.Info(Name, exception); - - public Task Verbose(string message, Exception exception = null) - => _manager.Verbose(Name, message, exception); - public Task Verbose(FormattableString message, Exception exception = null) - => _manager.Verbose(Name, message, exception); - public Task Verbose(Exception exception) - => _manager.Verbose(Name, exception); - - public Task Debug(string message, Exception exception = null) - => _manager.Debug(Name, message, exception); - public Task Debug(FormattableString message, Exception exception = null) - => _manager.Debug(Name, message, exception); - public Task Debug(Exception exception) - => _manager.Debug(Name, exception); + public Task LogAsync(LogSeverity severity, Exception exception = null) + => _manager.LogAsync(severity, Name, exception); + public Task LogAsync(LogSeverity severity, string message, Exception exception = null) + => _manager.LogAsync(severity, Name, message, exception); + public Task LogAsync(LogSeverity severity, FormattableString message, Exception exception = null) + => _manager.LogAsync(severity, Name, message, exception); + + public Task ErrorAsync(string message, Exception exception = null) + => _manager.ErrorAsync(Name, message, exception); + public Task ErrorAsync(FormattableString message, Exception exception = null) + => _manager.ErrorAsync(Name, message, exception); + public Task ErrorAsync(Exception exception) + => _manager.ErrorAsync(Name, exception); + + public Task WarningAsync(string message, Exception exception = null) + => _manager.WarningAsync(Name, message, exception); + public Task WarningAsync(FormattableString message, Exception exception = null) + => _manager.WarningAsync(Name, message, exception); + public Task WarningAsync(Exception exception) + => _manager.WarningAsync(Name, exception); + + public Task InfoAsync(string message, Exception exception = null) + => _manager.InfoAsync(Name, message, exception); + public Task InfoAsync(FormattableString message, Exception exception = null) + => _manager.InfoAsync(Name, message, exception); + public Task InfoAsync(Exception exception) + => _manager.InfoAsync(Name, exception); + + public Task VerboseAsync(string message, Exception exception = null) + => _manager.VerboseAsync(Name, message, exception); + public Task VerboseAsync(FormattableString message, Exception exception = null) + => _manager.VerboseAsync(Name, message, exception); + public Task VerboseAsync(Exception exception) + => _manager.VerboseAsync(Name, exception); + + public Task DebugAsync(string message, Exception exception = null) + => _manager.DebugAsync(Name, message, exception); + public Task DebugAsync(FormattableString message, Exception exception = null) + => _manager.DebugAsync(Name, message, exception); + public Task DebugAsync(Exception exception) + => _manager.DebugAsync(Name, exception); } } diff --git a/src/Discord.Net/Net/Converters/DiscordContractResolver.cs b/src/Discord.Net/Net/Converters/DiscordContractResolver.cs index 678dc83cd..f30e38cdd 100644 --- a/src/Discord.Net/Net/Converters/DiscordContractResolver.cs +++ b/src/Discord.Net/Net/Converters/DiscordContractResolver.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; @@ -11,58 +12,43 @@ namespace Discord.Net.Converters public class DiscordContractResolver : DefaultContractResolver { private static readonly TypeInfo _ienumerable = typeof(IEnumerable).GetTypeInfo(); - + private static readonly MethodInfo _shouldSerialize = typeof(DiscordContractResolver).GetTypeInfo().GetDeclaredMethod("ShouldSerialize"); + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) { var property = base.CreateProperty(member, memberSerialization); - var propInfo = member as PropertyInfo; + if (property.Ignored) + return property; + var propInfo = member as PropertyInfo; if (propInfo != null) { - JsonConverter converter = null; - var type = property.PropertyType; - var typeInfo = type.GetTypeInfo(); + JsonConverter converter; + var type = propInfo.PropertyType; - //Primitives - if (propInfo.GetCustomAttribute() == null) + if (type.IsConstructedGenericType && type.GetGenericTypeDefinition() == typeof(Optional<>)) { - if (type == typeof(ulong)) - converter = UInt64Converter.Instance; - else if (type == typeof(ulong?)) - converter = NullableUInt64Converter.Instance; - else if (typeInfo.ImplementedInterfaces.Any(x => x == typeof(IEnumerable))) - converter = UInt64ArrayConverter.Instance; - } - if (converter == null) - { - //Enums - if (type == typeof(ChannelType)) - converter = ChannelTypeConverter.Instance; - else if (type == typeof(PermissionTarget)) - converter = PermissionTargetConverter.Instance; - else if (type == typeof(UserStatus)) - converter = UserStatusConverter.Instance; + var typeInput = propInfo.DeclaringType; + var innerTypeOutput = type.GenericTypeArguments[0]; - //Entities - if (typeInfo.ImplementedInterfaces.Any(x => x == typeof(IEntity))) - converter = UInt64EntityConverter.Instance; - else if (typeInfo.ImplementedInterfaces.Any(x => x == typeof(IEntity))) - converter = StringEntityConverter.Instance; + var getter = typeof(Func<,>).MakeGenericType(typeInput, type); + var getterDelegate = propInfo.GetMethod.CreateDelegate(getter); + var shouldSerialize = _shouldSerialize.MakeGenericMethod(typeInput, innerTypeOutput); + var shouldSerializeDelegate = (Func)shouldSerialize.CreateDelegate(typeof(Func)); + property.ShouldSerialize = x => shouldSerializeDelegate(x, getterDelegate); - //Special - else if (type == typeof(string) && propInfo.GetCustomAttribute() != null) - converter = ImageConverter.Instance; - else if (type.IsConstructedGenericType && type.GetGenericTypeDefinition() == typeof(Optional<>)) + var converterType = typeof(OptionalConverter<>).MakeGenericType(innerTypeOutput).GetTypeInfo(); + var instanceField = converterType.GetDeclaredField("Instance"); + converter = instanceField.GetValue(null) as JsonConverter; + if (converter == null) { - var lambda = (Func)propInfo.GetMethod.CreateDelegate(typeof(Func)); - /*var parentArg = Expression.Parameter(typeof(object)); - var optional = Expression.Property(Expression.Convert(parentArg, property.DeclaringType), member as PropertyInfo); - var isSpecified = Expression.Property(optional, OptionalConverter.IsSpecifiedProperty); - var lambda = Expression.Lambda>(isSpecified, parentArg).Compile();*/ - property.ShouldSerialize = x => lambda(x); - converter = OptionalConverter.Instance; + var innerConverter = GetConverter(propInfo, innerTypeOutput); + converter = converterType.DeclaredConstructors.FirstOrDefault().Invoke(new object[] { innerConverter }) as JsonConverter; + instanceField.SetValue(null, converter); } } + else + converter = GetConverter(propInfo, type); if (converter != null) { @@ -70,8 +56,53 @@ namespace Discord.Net.Converters property.MemberConverter = converter; } } - return property; } + + private JsonConverter GetConverter(PropertyInfo propInfo, Type type, TypeInfo typeInfo = null) + { + bool hasInt53 = propInfo.GetCustomAttribute() != null; + + //Primitives + if (!hasInt53) + { + if (type == typeof(ulong)) + return UInt64Converter.Instance; + if (type == typeof(ulong?)) + return NullableUInt64Converter.Instance; + } + + //Enums + if (type == typeof(ChannelType)) + return ChannelTypeConverter.Instance; + if (type == typeof(PermissionTarget)) + return PermissionTargetConverter.Instance; + if (type == typeof(UserStatus)) + return UserStatusConverter.Instance; + + //Special + if (type == typeof(Stream) && propInfo.GetCustomAttribute() != null) + return ImageConverter.Instance; + + + if (typeInfo == null) typeInfo = type.GetTypeInfo(); + + //Primitives + if (!hasInt53 && typeInfo.ImplementedInterfaces.Any(x => x == typeof(IEnumerable))) + return UInt64ArrayConverter.Instance; + + //Entities + if (typeInfo.ImplementedInterfaces.Any(x => x == typeof(IEntity))) + return UInt64EntityConverter.Instance; + if (typeInfo.ImplementedInterfaces.Any(x => x == typeof(IEntity))) + return StringEntityConverter.Instance; + + return null; + } + + private static bool ShouldSerialize(object owner, Delegate getter) + { + return (getter as Func>)((TOwner)owner).IsSpecified; + } } } diff --git a/src/Discord.Net/Net/Converters/ImageConverter.cs b/src/Discord.Net/Net/Converters/ImageConverter.cs index a40b5bf86..3446d2b2e 100644 --- a/src/Discord.Net/Net/Converters/ImageConverter.cs +++ b/src/Discord.Net/Net/Converters/ImageConverter.cs @@ -1,5 +1,4 @@ -using Discord.API; -using Newtonsoft.Json; +using Newtonsoft.Json; using System; using System.IO; @@ -20,8 +19,6 @@ namespace Discord.Net.Converters public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - if (value is Optional) - value = (Optional)value; var stream = value as Stream; byte[] bytes = new byte[stream.Length - stream.Position]; diff --git a/src/Discord.Net/Net/Converters/OptionalConverter.cs b/src/Discord.Net/Net/Converters/OptionalConverter.cs index aa1abe9e2..260d642d4 100644 --- a/src/Discord.Net/Net/Converters/OptionalConverter.cs +++ b/src/Discord.Net/Net/Converters/OptionalConverter.cs @@ -1,27 +1,40 @@ -using Discord.API; -using Newtonsoft.Json; +using Newtonsoft.Json; using System; -using System.Reflection; namespace Discord.Net.Converters { - public class OptionalConverter : JsonConverter + public class OptionalConverter : JsonConverter { - public static readonly OptionalConverter Instance = new OptionalConverter(); - internal static readonly PropertyInfo IsSpecifiedProperty = typeof(IOptional).GetTypeInfo().GetDeclaredProperty(nameof(IOptional.IsSpecified)); + public static OptionalConverter Instance; + + private readonly JsonConverter _innerConverter; public override bool CanConvert(Type objectType) => true; - public override bool CanRead => false; + public override bool CanRead => true; public override bool CanWrite => true; + public OptionalConverter(JsonConverter innerConverter) + { + _innerConverter = innerConverter; + } + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { - throw new InvalidOperationException(); + T obj; + if (_innerConverter != null) + obj = (T)_innerConverter.ReadJson(reader, typeof(T), null, serializer); + else + obj = serializer.Deserialize(reader); + return new Optional(obj); } public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - serializer.Serialize(writer, (value as IOptional).Value); + value = ((Optional)value).Value; + if (_innerConverter != null) + _innerConverter.WriteJson(writer, value, serializer); + else + serializer.Serialize(writer, value, typeof(T)); } } } diff --git a/src/Discord.Net/Net/HttpException.cs b/src/Discord.Net/Net/HttpException.cs index 013db818a..d18d81abf 100644 --- a/src/Discord.Net/Net/HttpException.cs +++ b/src/Discord.Net/Net/HttpException.cs @@ -6,11 +6,13 @@ namespace Discord.Net public class HttpException : Exception { public HttpStatusCode StatusCode { get; } + public string Reason { get; } - public HttpException(HttpStatusCode statusCode) - : base($"The server responded with error {(int)statusCode} ({statusCode})") + public HttpException(HttpStatusCode statusCode, string reason = null) + : base($"The server responded with error {(int)statusCode} ({statusCode}){(reason != null ? $": \"{reason}\"" : "")}") { StatusCode = statusCode; + Reason = reason; } } } diff --git a/src/Discord.Net/Net/Queue/Definitions/BucketDefinition.cs b/src/Discord.Net/Net/Queue/Definitions/BucketDefinition.cs new file mode 100644 index 000000000..cfc53b0c8 --- /dev/null +++ b/src/Discord.Net/Net/Queue/Definitions/BucketDefinition.cs @@ -0,0 +1,30 @@ +namespace Discord.Net.Queue +{ + public sealed class Bucket + { + /// Gets the unique identifier for this bucket. + public string Id { get; } + /// Gets the name of this bucket. + public string Name { get; } + /// Gets the amount of requests that may be sent per window. + public int WindowCount { get; } + /// Gets the length of this bucket's window, in seconds. + public int WindowSeconds { get; } + /// Gets the type of account this bucket affects. + public BucketTarget Target { get; } + /// Gets this bucket's parent. + public GlobalBucket? Parent { get; } + + internal Bucket(string id, int windowCount, int windowSeconds, BucketTarget target, GlobalBucket? parent = null) + : this(id, id, windowCount, windowSeconds, target, parent) { } + internal Bucket(string id, string name, int windowCount, int windowSeconds, BucketTarget target, GlobalBucket? parent = null) + { + Id = id; + Name = name; + WindowCount = windowCount; + WindowSeconds = windowSeconds; + Target = target; + Parent = parent; + } + } +} diff --git a/src/Discord.Net/Net/Queue/BucketGroup.cs b/src/Discord.Net/Net/Queue/Definitions/BucketGroup.cs similarity index 51% rename from src/Discord.Net/Net/Queue/BucketGroup.cs rename to src/Discord.Net/Net/Queue/Definitions/BucketGroup.cs index 161f08432..e7b0a4181 100644 --- a/src/Discord.Net/Net/Queue/BucketGroup.cs +++ b/src/Discord.Net/Net/Queue/Definitions/BucketGroup.cs @@ -1,8 +1,9 @@ namespace Discord.Net.Queue { - internal enum BucketGroup + public enum BucketGroup { Global, - Guild + Guild, + Channel } } diff --git a/src/Discord.Net/Net/Queue/Definitions/BucketTarget.cs b/src/Discord.Net/Net/Queue/Definitions/BucketTarget.cs new file mode 100644 index 000000000..0e5a5d552 --- /dev/null +++ b/src/Discord.Net/Net/Queue/Definitions/BucketTarget.cs @@ -0,0 +1,9 @@ +namespace Discord.Net.Queue +{ + public enum BucketTarget + { + Client, + Bot, + Both + } +} diff --git a/src/Discord.Net/Net/Queue/Definitions/ChannelBucket.cs b/src/Discord.Net/Net/Queue/Definitions/ChannelBucket.cs new file mode 100644 index 000000000..235e6dfdf --- /dev/null +++ b/src/Discord.Net/Net/Queue/Definitions/ChannelBucket.cs @@ -0,0 +1,7 @@ +namespace Discord.Net.Queue +{ + public enum ChannelBucket + { + SendEditMessage, + } +} diff --git a/src/Discord.Net/Net/Queue/GlobalBucket.cs b/src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs similarity index 74% rename from src/Discord.Net/Net/Queue/GlobalBucket.cs rename to src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs index d1e011ffd..7d4ebb761 100644 --- a/src/Discord.Net/Net/Queue/GlobalBucket.cs +++ b/src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs @@ -2,11 +2,10 @@ { public enum GlobalBucket { - General, - Login, + GeneralRest, DirectMessage, SendEditMessage, - Gateway, + GeneralGateway, UpdateStatus } } diff --git a/src/Discord.Net/Net/Queue/GuildBucket.cs b/src/Discord.Net/Net/Queue/Definitions/GuildBucket.cs similarity index 100% rename from src/Discord.Net/Net/Queue/GuildBucket.cs rename to src/Discord.Net/Net/Queue/Definitions/GuildBucket.cs diff --git a/src/Discord.Net/Net/Queue/IQueuedRequest.cs b/src/Discord.Net/Net/Queue/IQueuedRequest.cs index e5575046e..ad0c8fcb6 100644 --- a/src/Discord.Net/Net/Queue/IQueuedRequest.cs +++ b/src/Discord.Net/Net/Queue/IQueuedRequest.cs @@ -4,10 +4,13 @@ using System.Threading.Tasks; namespace Discord.Net.Queue { + //TODO: Allow user-supplied canceltoken + //TODO: Allow specifying timeout via DiscordApiClient internal interface IQueuedRequest { - TaskCompletionSource Promise { get; } CancellationToken CancelToken { get; } - Task Send(); + int? TimeoutTick { get; } + + Task SendAsync(); } } diff --git a/src/Discord.Net/Net/Queue/IRequestQueue.cs b/src/Discord.Net/Net/Queue/IRequestQueue.cs deleted file mode 100644 index 75a820934..000000000 --- a/src/Discord.Net/Net/Queue/IRequestQueue.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Threading.Tasks; - -namespace Discord.Net.Queue -{ - //TODO: Add docstrings - public interface IRequestQueue - { - Task Clear(GlobalBucket type); - Task Clear(GuildBucket type, ulong guildId); - } -} diff --git a/src/Discord.Net/Net/Queue/RequestQueue.cs b/src/Discord.Net/Net/Queue/RequestQueue.cs index 365ebfb68..826bdd5fa 100644 --- a/src/Discord.Net/Net/Queue/RequestQueue.cs +++ b/src/Discord.Net/Net/Queue/RequestQueue.cs @@ -1,165 +1,171 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Threading; using System.Threading.Tasks; namespace Discord.Net.Queue { - public class RequestQueue : IRequestQueue + public class RequestQueue { + public event Func RateLimitTriggered; + + private readonly static ImmutableDictionary _globalLimits; + private readonly static ImmutableDictionary _guildLimits; + private readonly static ImmutableDictionary _channelLimits; private readonly SemaphoreSlim _lock; private readonly RequestQueueBucket[] _globalBuckets; - private readonly Dictionary[] _guildBuckets; + private readonly ConcurrentDictionary[] _guildBuckets; + private readonly ConcurrentDictionary[] _channelBuckets; private CancellationTokenSource _clearToken; private CancellationToken _parentToken; private CancellationToken _cancelToken; + static RequestQueue() + { + _globalLimits = new Dictionary + { + //REST + [GlobalBucket.GeneralRest] = new Bucket(null, "rest", 0, 0, BucketTarget.Both), //No Limit + //[GlobalBucket.Login] = new BucketDefinition(1, 1), + [GlobalBucket.DirectMessage] = new Bucket("bot:msg:dm", 5, 5, BucketTarget.Bot), + [GlobalBucket.SendEditMessage] = new Bucket("bot:msg:global", 50, 10, BucketTarget.Bot), + //[GlobalBucket.Username] = new Bucket("bot:msg:global", 2, 3600, BucketTarget.Both), + + //Gateway + [GlobalBucket.GeneralGateway] = new Bucket(null, "gateway", 120, 60, BucketTarget.Both), + [GlobalBucket.UpdateStatus] = new Bucket(null, "status", 5, 1, BucketTarget.Both, GlobalBucket.GeneralGateway) + }.ToImmutableDictionary(); + + _guildLimits = new Dictionary + { + //REST + [GuildBucket.SendEditMessage] = new Bucket("bot:msg:server", 5, 5, BucketTarget.Bot, GlobalBucket.SendEditMessage), + [GuildBucket.DeleteMessage] = new Bucket("dmsg", 5, 1, BucketTarget.Bot), + [GuildBucket.DeleteMessages] = new Bucket("bdmsg", 1, 1, BucketTarget.Bot), + [GuildBucket.ModifyMember] = new Bucket("guild_member", 10, 10, BucketTarget.Bot), + [GuildBucket.Nickname] = new Bucket("guild_member_nick", 1, 1, BucketTarget.Bot) + }.ToImmutableDictionary(); + + //Client-Only + _channelLimits = new Dictionary + { + //REST + [ChannelBucket.SendEditMessage] = new Bucket("msg", 10, 10, BucketTarget.Client, GlobalBucket.SendEditMessage), + }.ToImmutableDictionary(); + } + + public static Bucket GetBucketInfo(GlobalBucket bucket) => _globalLimits[bucket]; + public static Bucket GetBucketInfo(GuildBucket bucket) => _guildLimits[bucket]; + public static Bucket GetBucketInfo(ChannelBucket bucket) => _channelLimits[bucket]; + public RequestQueue() { _lock = new SemaphoreSlim(1, 1); - _globalBuckets = new RequestQueueBucket[Enum.GetValues(typeof(GlobalBucket)).Length]; - _guildBuckets = new Dictionary[Enum.GetValues(typeof(GuildBucket)).Length]; + + _globalBuckets = new RequestQueueBucket[_globalLimits.Count]; + foreach (var pair in _globalLimits) + { + //var target = _globalLimits[pair.Key].Target; + //if (target == BucketTarget.Both || (target == BucketTarget.Bot && isBot) || (target == BucketTarget.Client && !isBot)) + _globalBuckets[(int)pair.Key] = CreateBucket(pair.Value); + } + + _guildBuckets = new ConcurrentDictionary[_guildLimits.Count]; + for (int i = 0; i < _guildLimits.Count; i++) + { + //var target = _guildLimits[(GuildBucket)i].Target; + //if (target == BucketTarget.Both || (target == BucketTarget.Bot && isBot) || (target == BucketTarget.Client && !isBot)) + _guildBuckets[i] = new ConcurrentDictionary(); + } + + _channelBuckets = new ConcurrentDictionary[_channelLimits.Count]; + for (int i = 0; i < _channelLimits.Count; i++) + { + //var target = _channelLimits[(GuildBucket)i].Target; + //if (target == BucketTarget.Both || (target == BucketTarget.Bot && isBot) || (target == BucketTarget.Client && !isBot)) + _channelBuckets[i] = new ConcurrentDictionary(); + } _clearToken = new CancellationTokenSource(); _cancelToken = CancellationToken.None; _parentToken = CancellationToken.None; } - public async Task SetCancelToken(CancellationToken cancelToken) + public async Task SetCancelTokenAsync(CancellationToken cancelToken) { - await Lock().ConfigureAwait(false); + await _lock.WaitAsync().ConfigureAwait(false); try { _parentToken = cancelToken; _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, _clearToken.Token).Token; } - finally { Unlock(); } + finally { _lock.Release(); } } - internal Task Send(RestRequest request, BucketGroup group, int bucketId, ulong guildId) + internal async Task SendAsync(RestRequest request, BucketGroup group, int bucketId, ulong objId) { request.CancelToken = _cancelToken; - return Send(request as IQueuedRequest, group, bucketId, guildId); + var bucket = GetBucket(group, bucketId, objId); + return await bucket.SendAsync(request).ConfigureAwait(false); } - internal Task Send(WebSocketRequest request, BucketGroup group, int bucketId, ulong guildId) + internal async Task SendAsync(WebSocketRequest request, BucketGroup group, int bucketId, ulong objId) { request.CancelToken = _cancelToken; - return Send(request as IQueuedRequest, group, bucketId, guildId); + var bucket = GetBucket(group, bucketId, objId); + return await bucket.SendAsync(request).ConfigureAwait(false); } - private async Task Send(IQueuedRequest request, BucketGroup group, int bucketId, ulong guildId) + + private RequestQueueBucket CreateBucket(Bucket def) { - RequestQueueBucket bucket; - - await Lock().ConfigureAwait(false); - try - { - bucket = GetBucket(group, bucketId, guildId); - bucket.Queue(request); - } - finally { Unlock(); } - - //There is a chance the bucket will send this request on its own, but this will simply become a noop then. - var _ = bucket.ProcessQueue(acquireLock: true).ConfigureAwait(false); - - return await request.Promise.Task.ConfigureAwait(false); + var parent = def.Parent != null ? GetGlobalBucket(def.Parent.Value) : null; + return new RequestQueueBucket(this, def, parent); } - private RequestQueueBucket CreateBucket(GlobalBucket bucket) + public void DestroyGuildBucket(GuildBucket type, ulong guildId) { - switch (bucket) - { - //Globals - case GlobalBucket.General: return new RequestQueueBucket(this, bucket, int.MaxValue, 0); //Catch-all - case GlobalBucket.Login: return new RequestQueueBucket(this, bucket, 1, 1); //TODO: Is this actual logins or token validations too? - case GlobalBucket.DirectMessage: return new RequestQueueBucket(this, bucket, 5, 5); - case GlobalBucket.SendEditMessage: return new RequestQueueBucket(this, bucket, 50, 10); - case GlobalBucket.Gateway: return new RequestQueueBucket(this, bucket, 120, 60); - case GlobalBucket.UpdateStatus: return new RequestQueueBucket(this, bucket, 5, 1, GlobalBucket.Gateway); - - default: throw new ArgumentException($"Unknown global bucket: {bucket}", nameof(bucket)); - } + //Assume this object is locked + RequestQueueBucket bucket; + _guildBuckets[(int)type].TryRemove(guildId, out bucket); } - private RequestQueueBucket CreateBucket(GuildBucket bucket, ulong guildId) + public void DestroyChannelBucket(ChannelBucket type, ulong channelId) { - switch (bucket) - { - //Per Guild - case GuildBucket.SendEditMessage: return new RequestQueueBucket(this, bucket, guildId, 5, 5, GlobalBucket.SendEditMessage); - case GuildBucket.DeleteMessage: return new RequestQueueBucket(this, bucket, guildId, 5, 1); - case GuildBucket.DeleteMessages: return new RequestQueueBucket(this, bucket, guildId, 1, 1); - case GuildBucket.ModifyMember: return new RequestQueueBucket(this, bucket, guildId, 10, 10); //TODO: Is this all users or just roles? - case GuildBucket.Nickname: return new RequestQueueBucket(this, bucket, guildId, 1, 1); - - default: throw new ArgumentException($"Unknown guild bucket: {bucket}", nameof(bucket)); - } + //Assume this object is locked + RequestQueueBucket bucket; + _channelBuckets[(int)type].TryRemove(channelId, out bucket); } - private RequestQueueBucket GetBucket(BucketGroup group, int bucketId, ulong guildId) + private RequestQueueBucket GetBucket(BucketGroup group, int bucketId, ulong objId) { switch (group) { case BucketGroup.Global: return GetGlobalBucket((GlobalBucket)bucketId); case BucketGroup.Guild: - return GetGuildBucket((GuildBucket)bucketId, guildId); + return GetGuildBucket((GuildBucket)bucketId, objId); + case BucketGroup.Channel: + return GetChannelBucket((ChannelBucket)bucketId, objId); default: throw new ArgumentException($"Unknown bucket group: {group}", nameof(group)); } } private RequestQueueBucket GetGlobalBucket(GlobalBucket type) { - var bucket = _globalBuckets[(int)type]; - if (bucket == null) - { - bucket = CreateBucket(type); - _globalBuckets[(int)type] = bucket; - } - return bucket; + return _globalBuckets[(int)type]; } private RequestQueueBucket GetGuildBucket(GuildBucket type, ulong guildId) { - var bucketGroup = _guildBuckets[(int)type]; - if (bucketGroup == null) - { - bucketGroup = new Dictionary(); - _guildBuckets[(int)type] = bucketGroup; - } - RequestQueueBucket bucket; - if (!bucketGroup.TryGetValue(guildId, out bucket)) - { - bucket = CreateBucket(type, guildId); - bucketGroup[guildId] = bucket; - } - return bucket; + return _guildBuckets[(int)type].GetOrAdd(guildId, _ => CreateBucket(_guildLimits[type])); } - - public void DestroyGlobalBucket(GlobalBucket type) + private RequestQueueBucket GetChannelBucket(ChannelBucket type, ulong channelId) { - //Assume this object is locked - - _globalBuckets[(int)type] = null; + return _channelBuckets[(int)type].GetOrAdd(channelId, _ => CreateBucket(_channelLimits[type])); } - public void DestroyGuildBucket(GuildBucket type, ulong guildId) - { - //Assume this object is locked - var bucketGroup = _guildBuckets[(int)type]; - if (bucketGroup != null) - bucketGroup.Remove(guildId); - } - - public async Task Lock() - { - await _lock.WaitAsync(); - } - public void Unlock() - { - _lock.Release(); - } - - public async Task Clear() + public async Task ClearAsync() { - await Lock().ConfigureAwait(false); + await _lock.WaitAsync().ConfigureAwait(false); try { _clearToken?.Cancel(); @@ -169,37 +175,12 @@ namespace Discord.Net.Queue else _cancelToken = _clearToken.Token; } - finally { Unlock(); } + finally { _lock.Release(); } } - public async Task Clear(GlobalBucket type) - { - var bucket = _globalBuckets[(int)type]; - if (bucket != null) - { - try - { - await bucket.Lock().ConfigureAwait(false); - bucket.Clear(); - } - finally { bucket.Unlock(); } - } - } - public async Task Clear(GuildBucket type, ulong guildId) + + internal async Task RaiseRateLimitTriggered(string id, Bucket bucket, int millis) { - var bucketGroup = _guildBuckets[(int)type]; - if (bucketGroup != null) - { - RequestQueueBucket bucket; - if (bucketGroup.TryGetValue(guildId, out bucket)) - { - try - { - await bucket.Lock().ConfigureAwait(false); - bucket.Clear(); - } - finally { bucket.Unlock(); } - } - } + await RateLimitTriggered.Invoke(id, bucket, millis).ConfigureAwait(false); } } } diff --git a/src/Discord.Net/Net/Queue/RequestQueueBucket.cs b/src/Discord.Net/Net/Queue/RequestQueueBucket.cs index 7b05fb0fe..3c914315d 100644 --- a/src/Discord.Net/Net/Queue/RequestQueueBucket.cs +++ b/src/Discord.Net/Net/Queue/RequestQueueBucket.cs @@ -1,5 +1,5 @@ -using System; -using System.Collections.Concurrent; +#pragma warning disable CS4014 +using System; using System.IO; using System.Net; using System.Threading; @@ -7,185 +7,157 @@ using System.Threading.Tasks; namespace Discord.Net.Queue { - //TODO: Implement bucket chaining internal class RequestQueueBucket { - private readonly RequestQueue _parent; - private readonly BucketGroup _bucketGroup; - private readonly GlobalBucket? _chainedBucket; - private readonly int _bucketId; - private readonly ulong _guildId; - private readonly ConcurrentQueue _queue; - private readonly SemaphoreSlim _lock; - private Task _resetTask; - private bool _waitingToProcess; - private int _id; + private readonly RequestQueue _queue; + private readonly SemaphoreSlim _semaphore; + private readonly object _pauseLock; + private int _pauseEndTick; + private TaskCompletionSource _resumeNotifier; - public int WindowMaxCount { get; } - public int WindowSeconds { get; } - public int WindowCount { get; private set; } + public Bucket Definition { get; } + public RequestQueueBucket Parent { get; } + public Task _resetTask { get; } - public RequestQueueBucket(RequestQueue parent, GlobalBucket bucket, int windowMaxCount, int windowSeconds, GlobalBucket? chainedBucket = null) - : this(parent, windowMaxCount, windowSeconds, chainedBucket) + public RequestQueueBucket(RequestQueue queue, Bucket definition, RequestQueueBucket parent = null) { - _bucketGroup = BucketGroup.Global; - _bucketId = (int)bucket; - _guildId = 0; - } - public RequestQueueBucket(RequestQueue parent, GuildBucket bucket, ulong guildId, int windowMaxCount, int windowSeconds, GlobalBucket? chainedBucket = null) - : this(parent, windowMaxCount, windowSeconds, chainedBucket) - { - _bucketGroup = BucketGroup.Guild; - _bucketId = (int)bucket; - _guildId = guildId; - } - private RequestQueueBucket(RequestQueue parent, int windowMaxCount, int windowSeconds, GlobalBucket? chainedBucket = null) - { - _parent = parent; - WindowMaxCount = windowMaxCount; - WindowSeconds = windowSeconds; - _chainedBucket = chainedBucket; - _queue = new ConcurrentQueue(); - _lock = new SemaphoreSlim(1, 1); - _id = new System.Random().Next(0, int.MaxValue); + _queue = queue; + Definition = definition; + if (definition.WindowCount != 0) + _semaphore = new SemaphoreSlim(definition.WindowCount, definition.WindowCount); + Parent = parent; + + _pauseLock = new object(); + _resumeNotifier = new TaskCompletionSource(); + _resumeNotifier.SetResult(0); } - public void Queue(IQueuedRequest request) - { - _queue.Enqueue(request); - } - public async Task ProcessQueue(bool acquireLock = false) + public async Task SendAsync(IQueuedRequest request) { - //Assume this obj is under lock + while (true) + { + try + { + return await SendAsyncInternal(request).ConfigureAwait(false); + } + catch (HttpRateLimitException ex) + { + //When a 429 occurs, we drop all our locks, including the ones we wanted. + //This is generally safe though since 429s actually occuring should be very rare. + RequestQueueBucket bucket; + bool success = FindBucket(ex.BucketId, out bucket); - int nextRetry = 1000; + await _queue.RaiseRateLimitTriggered(ex.BucketId, success ? bucket.Definition : null, ex.RetryAfterMilliseconds).ConfigureAwait(false); - //If we have another ProcessQueue waiting to run, dont bother with this one - if (_waitingToProcess) return; - _waitingToProcess = true; + bucket.Pause(ex.RetryAfterMilliseconds); + } + } + } + private async Task SendAsyncInternal(IQueuedRequest request) + { + var endTick = request.TimeoutTick; - if (acquireLock) - await Lock().ConfigureAwait(false); + //Wait until a spot is open in our bucket + if (_semaphore != null) + await EnterAsync(endTick).ConfigureAwait(false); try { - _waitingToProcess = false; while (true) { - IQueuedRequest request; - - //If we're waiting to reset (due to a rate limit exception, or preemptive check), abort - if (WindowCount == WindowMaxCount) return; - //Get next request, return if queue is empty - if (!_queue.TryPeek(out request)) break; - - try - { - if (request.CancelToken.IsCancellationRequested) - request.Promise.SetException(new OperationCanceledException(request.CancelToken)); - else - { - Stream stream = await request.Send().ConfigureAwait(false); - request.Promise.SetResult(stream); - } - } - catch (HttpRateLimitException ex) //Preemptive check failed, use Discord's time instead of our own - { - WindowCount = WindowMaxCount; - var task = _resetTask; - if (task != null) - { - var retryAfter = DateTime.UtcNow.AddMilliseconds(ex.RetryAfterMilliseconds); - await task.ConfigureAwait(false); - int millis = (int)Math.Ceiling((DateTime.UtcNow - retryAfter).TotalMilliseconds); - _resetTask = ResetAfter(millis); - } - else - _resetTask = ResetAfter(ex.RetryAfterMilliseconds); - return; - } - catch (HttpException ex) + //Get our 429 state + Task notifier; + int resumeTime; + + lock (_pauseLock) { - if (ex.StatusCode == HttpStatusCode.BadGateway) //Gateway unavailable, retry - { - await Task.Delay(nextRetry).ConfigureAwait(false); - nextRetry *= 2; - if (nextRetry > 30000) - nextRetry = 30000; - continue; - } - else - { - //We dont need to throw this here, pass the exception via the promise - request.Promise.SetException(ex); - } + notifier = _resumeNotifier.Task; + resumeTime = _pauseEndTick; } - //Request completed or had an error other than 429 - _queue.TryDequeue(out request); - WindowCount++; - nextRetry = 1000; - - if (WindowCount == 1 && WindowSeconds > 0) + //Are we paused due to a 429? + if (!notifier.IsCompleted) { - //First request for this window, schedule a reset - _resetTask = ResetAfter(WindowSeconds * 1000); + //If the 429 ends after the maximum time for this request, timeout immediately + if (endTick.HasValue && endTick.Value < resumeTime) + throw new TimeoutException(); + + //Wait for the 429 to complete + await notifier.ConfigureAwait(false); } - } - //If queue is empty, non-global, and there is no active rate limit, remove this bucket - if (_resetTask == null && _bucketGroup == BucketGroup.Guild) - { try { - await _parent.Lock().ConfigureAwait(false); - if (_queue.IsEmpty) //Double check, in case a request was queued before we got both locks - _parent.DestroyGuildBucket((GuildBucket)_bucketId, _guildId); + //If there's a parent bucket, pass this request to them + if (Parent != null) + return await Parent.SendAsyncInternal(request).ConfigureAwait(false); + + //We have all our semaphores, send the request + return await request.SendAsync().ConfigureAwait(false); } - finally + catch (HttpException ex) when (ex.StatusCode == HttpStatusCode.BadGateway) { - _parent.Unlock(); + continue; } } } finally { - if (acquireLock) - Unlock(); + //Make sure we put this entry back after WindowMilliseconds + if (_semaphore != null) + QueueExitAsync(); } } - public void Clear() - { - //Assume this obj is under lock - IQueuedRequest request; - while (_queue.TryDequeue(out request)) { } + private bool FindBucket(string id, out RequestQueueBucket bucket) + { + //Keep going up until we find a bucket with matching id or we're at the topmost bucket + if (Definition.Id == id) + { + bucket = this; + return true; + } + else if (Parent == null) + { + bucket = this; + return false; + } + else + return Parent.FindBucket(id, out bucket); } - private async Task ResetAfter(int milliseconds) + private void Pause(int milliseconds) { - if (milliseconds > 0) - await Task.Delay(milliseconds).ConfigureAwait(false); - try + lock (_pauseLock) { - await Lock().ConfigureAwait(false); - - //Reset the current window count and set our state back to normal - WindowCount = 0; - _resetTask = null; - - //Wait is over, work through the current queue - await ProcessQueue().ConfigureAwait(false); + //If we aren't already waiting on a 429's time, create a new notifier task + if (_resumeNotifier.Task.IsCompleted) + { + _resumeNotifier = new TaskCompletionSource(); + _pauseEndTick = unchecked(Environment.TickCount + milliseconds); + QueueResumeAsync(_resumeNotifier, milliseconds); + } } - finally { Unlock(); } + } + private async Task QueueResumeAsync(TaskCompletionSource resumeNotifier, int millis) + { + await Task.Delay(millis).ConfigureAwait(false); + resumeNotifier.SetResult(0); } - public async Task Lock() + private async Task EnterAsync(int? endTick) { - await _lock.WaitAsync(); + if (endTick.HasValue) + { + int millis = unchecked(Environment.TickCount - endTick.Value); + if (millis <= 0 || !await _semaphore.WaitAsync(millis).ConfigureAwait(false)) + throw new TimeoutException(); + } + await _semaphore.WaitAsync().ConfigureAwait(false); } - public void Unlock() + private async Task QueueExitAsync() { - _lock.Release(); + await Task.Delay(Definition.WindowSeconds * 1000).ConfigureAwait(false); + _semaphore.Release(); } } } diff --git a/src/Discord.Net/Net/Queue/RestRequest.cs b/src/Discord.Net/Net/Queue/RestRequest.cs index 7c71d114a..aa63eacb5 100644 --- a/src/Discord.Net/Net/Queue/RestRequest.cs +++ b/src/Discord.Net/Net/Queue/RestRequest.cs @@ -1,4 +1,5 @@ using Discord.Net.Rest; +using System; using System.Collections.Generic; using System.IO; using System.Threading; @@ -13,43 +14,47 @@ namespace Discord.Net.Queue public string Endpoint { get; } public string Json { get; } public bool HeaderOnly { get; } + public int? TimeoutTick { get; } public IReadOnlyDictionary MultipartParams { get; } public TaskCompletionSource Promise { get; } public CancellationToken CancelToken { get; set; } public bool IsMultipart => MultipartParams != null; - public RestRequest(IRestClient client, string method, string endpoint, string json, bool headerOnly) - : this(client, method, endpoint, headerOnly) + public RestRequest(IRestClient client, string method, string endpoint, string json, bool headerOnly, RequestOptions options) + : this(client, method, endpoint, headerOnly, options) { Json = json; } - public RestRequest(IRestClient client, string method, string endpoint, IReadOnlyDictionary multipartParams, bool headerOnly) - : this(client, method, endpoint, headerOnly) + public RestRequest(IRestClient client, string method, string endpoint, IReadOnlyDictionary multipartParams, bool headerOnly, RequestOptions options) + : this(client, method, endpoint, headerOnly, options) { MultipartParams = multipartParams; } - private RestRequest(IRestClient client, string method, string endpoint, bool headerOnly) + private RestRequest(IRestClient client, string method, string endpoint, bool headerOnly, RequestOptions options) { + var timeout = options?.Timeout; + Client = client; Method = method; Endpoint = endpoint; Json = null; MultipartParams = null; HeaderOnly = headerOnly; + TimeoutTick = timeout.HasValue ? (int?)unchecked(Environment.TickCount + timeout.Value) : null; Promise = new TaskCompletionSource(); } - public async Task Send() + public async Task SendAsync() { if (IsMultipart) - return await Client.Send(Method, Endpoint, MultipartParams, HeaderOnly).ConfigureAwait(false); + return await Client.SendAsync(Method, Endpoint, MultipartParams, HeaderOnly).ConfigureAwait(false); else if (Json != null) - return await Client.Send(Method, Endpoint, Json, HeaderOnly).ConfigureAwait(false); + return await Client.SendAsync(Method, Endpoint, Json, HeaderOnly).ConfigureAwait(false); else - return await Client.Send(Method, Endpoint, HeaderOnly).ConfigureAwait(false); + return await Client.SendAsync(Method, Endpoint, HeaderOnly).ConfigureAwait(false); } } } diff --git a/src/Discord.Net/Net/Queue/WebSocketRequest.cs b/src/Discord.Net/Net/Queue/WebSocketRequest.cs index bd8f492c3..1a841b603 100644 --- a/src/Discord.Net/Net/Queue/WebSocketRequest.cs +++ b/src/Discord.Net/Net/Queue/WebSocketRequest.cs @@ -1,4 +1,5 @@ using Discord.Net.WebSockets; +using System; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -12,23 +13,28 @@ namespace Discord.Net.Queue public int DataIndex { get; } public int DataCount { get; } public bool IsText { get; } + public int? TimeoutTick { get; } public TaskCompletionSource Promise { get; } public CancellationToken CancelToken { get; set; } - public WebSocketRequest(IWebSocketClient client, byte[] data, bool isText) : this(client, data, 0, data.Length, isText) { } - public WebSocketRequest(IWebSocketClient client, byte[] data, int index, int count, bool isText) + public WebSocketRequest(IWebSocketClient client, byte[] data, bool isText, RequestOptions options) : this(client, data, 0, data.Length, isText, options) { } + public WebSocketRequest(IWebSocketClient client, byte[] data, int index, int count, bool isText, RequestOptions options) { Client = client; Data = data; DataIndex = index; DataCount = count; IsText = isText; + if (options != null) + TimeoutTick = unchecked(Environment.TickCount + options.Timeout.Value); + else + TimeoutTick = null; Promise = new TaskCompletionSource(); } - public async Task Send() + public async Task SendAsync() { - await Client.Send(Data, DataIndex, DataCount, IsText).ConfigureAwait(false); + await Client.SendAsync(Data, DataIndex, DataCount, IsText).ConfigureAwait(false); return null; } } diff --git a/src/Discord.Net/Net/RateLimitException.cs b/src/Discord.Net/Net/RateLimitException.cs index a07e90760..ff594155a 100644 --- a/src/Discord.Net/Net/RateLimitException.cs +++ b/src/Discord.Net/Net/RateLimitException.cs @@ -4,11 +4,13 @@ namespace Discord.Net { public class HttpRateLimitException : HttpException { + public string BucketId { get; } public int RetryAfterMilliseconds { get; } - public HttpRateLimitException(int retryAfterMilliseconds) - : base((HttpStatusCode)429) + public HttpRateLimitException(string bucketId, int retryAfterMilliseconds, string reason) + : base((HttpStatusCode)429, reason) { + BucketId = bucketId; RetryAfterMilliseconds = retryAfterMilliseconds; } } diff --git a/src/Discord.Net/Net/Rest/DefaultRestClient.cs b/src/Discord.Net/Net/Rest/DefaultRestClient.cs index 9d83e9a32..dd937d6ec 100644 --- a/src/Discord.Net/Net/Rest/DefaultRestClient.cs +++ b/src/Discord.Net/Net/Rest/DefaultRestClient.cs @@ -1,4 +1,6 @@ -using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -11,15 +13,16 @@ using System.Threading.Tasks; namespace Discord.Net.Rest { - public class DefaultRestClient : IRestClient + public sealed class DefaultRestClient : IRestClient { private const int HR_SECURECHANNELFAILED = -2146233079; - protected readonly HttpClient _client; - protected readonly string _baseUrl; + private readonly HttpClient _client; + private readonly string _baseUrl; + private readonly JsonSerializer _errorDeserializer; private CancellationTokenSource _cancelTokenSource; private CancellationToken _cancelToken, _parentToken; - protected bool _isDisposed; + private bool _isDisposed; public DefaultRestClient(string baseUrl) { @@ -29,16 +32,16 @@ namespace Discord.Net.Rest { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, UseCookies = false, - UseProxy = false, - PreAuthenticate = false + UseProxy = false }); SetHeader("accept-encoding", "gzip, deflate"); _cancelTokenSource = new CancellationTokenSource(); _cancelToken = CancellationToken.None; _parentToken = CancellationToken.None; + _errorDeserializer = new JsonSerializer(); } - protected virtual void Dispose(bool disposing) + private void Dispose(bool disposing) { if (!_isDisposed) { @@ -64,22 +67,22 @@ namespace Discord.Net.Rest _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _cancelTokenSource.Token).Token; } - public async Task Send(string method, string endpoint, bool headerOnly = false) + public async Task SendAsync(string method, string endpoint, bool headerOnly = false) { string uri = Path.Combine(_baseUrl, endpoint); using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) - return await SendInternal(restRequest, headerOnly).ConfigureAwait(false); + return await SendInternalAsync(restRequest, headerOnly).ConfigureAwait(false); } - public async Task Send(string method, string endpoint, string json, bool headerOnly = false) + public async Task SendAsync(string method, string endpoint, string json, bool headerOnly = false) { string uri = Path.Combine(_baseUrl, endpoint); using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) { restRequest.Content = new StringContent(json, Encoding.UTF8, "application/json"); - return await SendInternal(restRequest, headerOnly).ConfigureAwait(false); + return await SendInternalAsync(restRequest, headerOnly).ConfigureAwait(false); } } - public async Task Send(string method, string endpoint, IReadOnlyDictionary multipartParams, bool headerOnly = false) + public async Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, bool headerOnly = false) { string uri = Path.Combine(_baseUrl, endpoint); using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) @@ -89,25 +92,7 @@ namespace Discord.Net.Rest { foreach (var p in multipartParams) { -#if CSHARP7 - switch (p.Value) - { - case string value: - content.Add(new StringContent(value), p.Key); - break; - case byte[] value: - content.Add(new ByteArrayContent(value), p.Key); - break; - case Stream value: - content.Add(new StreamContent(value), p.Key); - break; - case MultipartFile value: - content.Add(new StreamContent(value.Stream), value.Filename, p.Key); - break; - default: - throw new InvalidOperationException($"Unsupported param type \"{p.Value.GetType().Name}\""); - } -#else + //TODO: C#7 Typeswitch candidate var stringValue = p.Value as string; if (stringValue != null) { content.Add(new StringContent(stringValue), p.Key); continue; } var byteArrayValue = p.Value as byte[]; @@ -122,15 +107,14 @@ namespace Discord.Net.Rest } throw new InvalidOperationException($"Unsupported param type \"{p.Value.GetType().Name}\""); -#endif } } restRequest.Content = content; - return await SendInternal(restRequest, headerOnly).ConfigureAwait(false); + return await SendInternalAsync(restRequest, headerOnly).ConfigureAwait(false); } } - private async Task SendInternal(HttpRequestMessage request, bool headerOnly) + private async Task SendInternalAsync(HttpRequestMessage request, bool headerOnly) { while (true) { @@ -140,9 +124,32 @@ namespace Discord.Net.Rest int statusCode = (int)response.StatusCode; if (statusCode < 200 || statusCode >= 300) //2xx = Success { - if (statusCode == 429) - throw new HttpRateLimitException(int.Parse(response.Headers.GetValues("retry-after").First())); - throw new HttpException(response.StatusCode); + string reason = null; + JToken content = null; + if (response.Content.Headers.GetValues("content-type").FirstOrDefault() == "application/json") + { + try + { + using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + using (var reader = new StreamReader(stream)) + using (var json = new JsonTextReader(reader)) + { + content = _errorDeserializer.Deserialize(json); + reason = content.Value("message"); + } + } + catch { } //Might have been HTML Should we check for content-type? + } + + if (statusCode == 429 && content != null) + { + //TODO: Include bucket info + string bucketId = content.Value("bucket"); + int retryAfterMillis = content.Value("retry_after"); + throw new HttpRateLimitException(bucketId, retryAfterMillis, reason); + } + else + throw new HttpException(response.StatusCode, reason); } if (headerOnly) diff --git a/src/Discord.Net/Net/Rest/IRestClient.cs b/src/Discord.Net/Net/Rest/IRestClient.cs index 25b577688..57b5f91ca 100644 --- a/src/Discord.Net/Net/Rest/IRestClient.cs +++ b/src/Discord.Net/Net/Rest/IRestClient.cs @@ -11,8 +11,8 @@ namespace Discord.Net.Rest void SetHeader(string key, string value); void SetCancelToken(CancellationToken cancelToken); - Task Send(string method, string endpoint, bool headerOnly = false); - Task Send(string method, string endpoint, string json, bool headerOnly = false); - Task Send(string method, string endpoint, IReadOnlyDictionary multipartParams, bool headerOnly = false); + Task SendAsync(string method, string endpoint, bool headerOnly = false); + Task SendAsync(string method, string endpoint, string json, bool headerOnly = false); + Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, bool headerOnly = false); } } diff --git a/src/Discord.Net/Net/WebSocketException.cs b/src/Discord.Net/Net/WebSocketException.cs new file mode 100644 index 000000000..d647b6c8c --- /dev/null +++ b/src/Discord.Net/Net/WebSocketException.cs @@ -0,0 +1,16 @@ +using System; +namespace Discord.Net +{ + public class WebSocketClosedException : Exception + { + public int CloseCode { get; } + public string Reason { get; } + + public WebSocketClosedException(int closeCode, string reason = null) + : base($"The server sent close {closeCode}{(reason != null ? $": \"{reason}\"" : "")}") + { + CloseCode = closeCode; + Reason = reason; + } + } +} diff --git a/src/Discord.Net/Net/WebSockets/DefaultWebsocketClient.cs b/src/Discord.Net/Net/WebSockets/DefaultWebsocketClient.cs index d9559a2cf..d9c518874 100644 --- a/src/Discord.Net/Net/WebSockets/DefaultWebsocketClient.cs +++ b/src/Discord.Net/Net/WebSockets/DefaultWebsocketClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Net.WebSockets; @@ -10,14 +11,17 @@ namespace Discord.Net.WebSockets { public class DefaultWebSocketClient : IWebSocketClient { - public const int ReceiveChunkSize = 12 * 1024; //12KB + public const int ReceiveChunkSize = 16 * 1024; //16KB public const int SendChunkSize = 4 * 1024; //4KB private const int HR_TIMEOUT = -2147012894; public event Func BinaryMessage; public event Func TextMessage; - - private readonly ClientWebSocket _client; + public event Func Closed; + + private readonly SemaphoreSlim _sendLock; + private readonly Dictionary _headers; + private ClientWebSocket _client; private Task _task; private CancellationTokenSource _cancelTokenSource; private CancellationToken _cancelToken, _parentToken; @@ -25,13 +29,11 @@ namespace Discord.Net.WebSockets public DefaultWebSocketClient() { - _client = new ClientWebSocket(); - _client.Options.Proxy = null; - _client.Options.KeepAliveInterval = TimeSpan.Zero; - + _sendLock = new SemaphoreSlim(1, 1); _cancelTokenSource = new CancellationTokenSource(); _cancelToken = CancellationToken.None; _parentToken = CancellationToken.None; + _headers = new Dictionary(); } private void Dispose(bool disposing) { @@ -47,31 +49,44 @@ namespace Discord.Net.WebSockets Dispose(true); } - public async Task Connect(string host) + public async Task ConnectAsync(string host) { //Assume locked - await Disconnect().ConfigureAwait(false); + await DisconnectAsync().ConfigureAwait(false); _cancelTokenSource = new CancellationTokenSource(); _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _cancelTokenSource.Token).Token; + _client = new ClientWebSocket(); + _client.Options.Proxy = null; + _client.Options.KeepAliveInterval = TimeSpan.Zero; + foreach (var header in _headers) + { + if (header.Value != null) + _client.Options.SetRequestHeader(header.Key, header.Value); + } + await _client.ConnectAsync(new Uri(host), _cancelToken).ConfigureAwait(false); - _task = Run(_cancelToken); + _task = RunAsync(_cancelToken); } - public async Task Disconnect() + public async Task DisconnectAsync() { //Assume locked - _cancelTokenSource.Cancel(); - - if (_client.State == WebSocketState.Open) - try { await _client?.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); } catch { } + try { _cancelTokenSource.Cancel(false); } catch { } + + if (_client != null && _client.State == WebSocketState.Open) + { + var task = _client?.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + if (task != null) + await task.ConfigureAwait(false); + } await (_task ?? Task.CompletedTask).ConfigureAwait(false); } public void SetHeader(string key, string value) { - _client.Options.SetRequestHeader(key, value); + _headers[key] = value; } public void SetCancelToken(CancellationToken cancelToken) { @@ -79,78 +94,108 @@ namespace Discord.Net.WebSockets _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _cancelTokenSource.Token).Token; } - public async Task Send(byte[] data, int index, int count, bool isText) + public async Task SendAsync(byte[] data, int index, int count, bool isText) { - //TODO: If connection is temporarily down, retry? - int frameCount = (int)Math.Ceiling((double)count / SendChunkSize); - - for (int i = 0; i < frameCount; i++, index += SendChunkSize) + await _sendLock.WaitAsync(_cancelToken).ConfigureAwait(false); + try { - bool isLast = i == (frameCount - 1); - - int frameSize; - if (isLast) - frameSize = count - (i * SendChunkSize); - else - frameSize = SendChunkSize; + //TODO: If connection is temporarily down, retry? + int frameCount = (int)Math.Ceiling((double)count / SendChunkSize); - try + for (int i = 0; i < frameCount; i++, index += SendChunkSize) { - await _client.SendAsync(new ArraySegment(data, index, count), isText ? WebSocketMessageType.Text : WebSocketMessageType.Binary, isLast, _cancelToken).ConfigureAwait(false); - } - catch (Win32Exception ex) when (ex.HResult == HR_TIMEOUT) - { - return; + bool isLast = i == (frameCount - 1); + + int frameSize; + if (isLast) + frameSize = count - (i * SendChunkSize); + else + frameSize = SendChunkSize; + + try + { + var type = isText ? WebSocketMessageType.Text : WebSocketMessageType.Binary; + await _client.SendAsync(new ArraySegment(data, index, count), type, isLast, _cancelToken).ConfigureAwait(false); + } + catch (Win32Exception ex) when (ex.HResult == HR_TIMEOUT) + { + return; + } } } + finally + { + _sendLock.Release(); + } } - - //TODO: Check this code - private async Task Run(CancellationToken cancelToken) + + private async Task RunAsync(CancellationToken cancelToken) { var buffer = new ArraySegment(new byte[ReceiveChunkSize]); - var stream = new MemoryStream(); try { while (!cancelToken.IsCancellationRequested) { - WebSocketReceiveResult result = null; - do + WebSocketReceiveResult socketResult = await _client.ReceiveAsync(buffer, cancelToken).ConfigureAwait(false); + byte[] result; + int resultCount; + + if (socketResult.MessageType == WebSocketMessageType.Close) { - if (cancelToken.IsCancellationRequested) return; + var _ = Closed(new WebSocketClosedException((int)socketResult.CloseStatus, socketResult.CloseStatusDescription)); + return; + } - try - { - result = await _client.ReceiveAsync(buffer, cancelToken).ConfigureAwait(false); - } - catch (Win32Exception ex) when (ex.HResult == HR_TIMEOUT) + if (!socketResult.EndOfMessage) + { + //This is a large message (likely just READY), lets create a temporary expandable stream + using (var stream = new MemoryStream()) { - throw new Exception("Connection timed out."); + stream.Write(buffer.Array, 0, socketResult.Count); + do + { + if (cancelToken.IsCancellationRequested) return; + socketResult = await _client.ReceiveAsync(buffer, cancelToken).ConfigureAwait(false); + stream.Write(buffer.Array, 0, socketResult.Count); + } + while (socketResult == null || !socketResult.EndOfMessage); + + //Use the internal buffer if we can get it + resultCount = (int)stream.Length; + ArraySegment streamBuffer; + if (stream.TryGetBuffer(out streamBuffer)) + result = streamBuffer.Array; + else + result = stream.ToArray(); } - - if (result.MessageType == WebSocketMessageType.Close) - throw new WebSocketException((int)result.CloseStatus.Value, result.CloseStatusDescription); - else - stream.Write(buffer.Array, 0, result.Count); - } - while (result == null || !result.EndOfMessage); - - var array = stream.ToArray(); - if (result.MessageType == WebSocketMessageType.Binary) - await BinaryMessage.Raise(array, 0, array.Length).ConfigureAwait(false); - else if (result.MessageType == WebSocketMessageType.Text) + else { - string text = Encoding.UTF8.GetString(array, 0, array.Length); - await TextMessage.Raise(text).ConfigureAwait(false); + //Small message + resultCount = socketResult.Count; + result = buffer.Array; } - stream.Position = 0; - stream.SetLength(0); + if (socketResult.MessageType == WebSocketMessageType.Text) + { + string text = Encoding.UTF8.GetString(result, 0, resultCount); + await TextMessage(text).ConfigureAwait(false); + } + else + await BinaryMessage(result, 0, resultCount).ConfigureAwait(false); } } + catch (Win32Exception ex) when (ex.HResult == HR_TIMEOUT) + { + var _ = Closed(new Exception("Connection timed out.", ex)); + } catch (OperationCanceledException) { } + catch (Exception ex) + { + //This cannot be awaited otherwise we'll deadlock when DiscordApiClient waits for this task to complete. + var _ = Closed(ex); + } } } } diff --git a/src/Discord.Net/Net/WebSockets/IWebSocketClient.cs b/src/Discord.Net/Net/WebSockets/IWebSocketClient.cs index 2925c1350..7eccaabf2 100644 --- a/src/Discord.Net/Net/WebSockets/IWebSocketClient.cs +++ b/src/Discord.Net/Net/WebSockets/IWebSocketClient.cs @@ -8,13 +8,14 @@ namespace Discord.Net.WebSockets { event Func BinaryMessage; event Func TextMessage; + event Func Closed; void SetHeader(string key, string value); void SetCancelToken(CancellationToken cancelToken); - Task Connect(string host); - Task Disconnect(); + Task ConnectAsync(string host); + Task DisconnectAsync(); - Task Send(byte[] data, int index, int count, bool isText); + Task SendAsync(byte[] data, int index, int count, bool isText); } } diff --git a/src/Discord.Net/Properties/AssemblyInfo.cs b/src/Discord.Net/Properties/AssemblyInfo.cs deleted file mode 100644 index 7dcbdb315..000000000 --- a/src/Discord.Net/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Discord.Net.Core")] -[assembly: AssemblyTrademark("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("91e9e7bd-75c9-4e98-84aa-2c271922e5c2")] diff --git a/src/Discord.Net/RequestOptions.cs b/src/Discord.Net/RequestOptions.cs new file mode 100644 index 000000000..16b5b4d76 --- /dev/null +++ b/src/Discord.Net/RequestOptions.cs @@ -0,0 +1,8 @@ +namespace Discord +{ + public class RequestOptions + { + /// The max time, in milliseconds, to wait for this request to complete. If null, a request will not time out. If a rate limit has been triggered for this request's bucket and will not be unpaused in time, this request will fail immediately. + public int? Timeout { get; set; } + } +} diff --git a/src/Discord.Net/Rest/DiscordClient.cs b/src/Discord.Net/Rest/DiscordClient.cs deleted file mode 100644 index 89475ca93..000000000 --- a/src/Discord.Net/Rest/DiscordClient.cs +++ /dev/null @@ -1,280 +0,0 @@ -using Discord.API.Rest; -using Discord.Logging; -using Discord.Net; -using Discord.Net.Queue; -using Discord.Net.Rest; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace Discord.Rest -{ - //TODO: Docstrings - //TODO: Log Internal/External REST Rate Limits, 502s - //TODO: Log Logins/Logouts - public sealed class DiscordClient : IDiscordClient, IDisposable - { - public event Func Log; - public event Func LoggedIn, LoggedOut; - - private readonly Logger _discordLogger, _restLogger; - private readonly SemaphoreSlim _connectionLock; - private readonly RestClientProvider _restClientProvider; - private readonly LogManager _log; - private readonly RequestQueue _requestQueue; - private bool _isDisposed; - private SelfUser _currentUser; - - public LoginState LoginState { get; private set; } - public API.DiscordApiClient ApiClient { get; private set; } - - public IRequestQueue RequestQueue => _requestQueue; - - public DiscordClient(DiscordConfig config = null) - { - if (config == null) - config = new DiscordConfig(); - - _log = new LogManager(config.LogLevel); - _log.Message += async msg => await Log.Raise(msg).ConfigureAwait(false); - _discordLogger = _log.CreateLogger("Discord"); - _restLogger = _log.CreateLogger("Rest"); - - _connectionLock = new SemaphoreSlim(1, 1); - _requestQueue = new RequestQueue(); - - ApiClient = new API.DiscordApiClient(config.RestClientProvider, requestQueue: _requestQueue); - ApiClient.SentRequest += async (method, endpoint, millis) => await _log.Verbose("Rest", $"{method} {endpoint}: {millis} ms").ConfigureAwait(false); - } - - public async Task Login(string email, string password) - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await LoginInternal(TokenType.User, null, email, password, true, false).ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - public async Task Login(TokenType tokenType, string token, bool validateToken = true) - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await LoginInternal(tokenType, token, null, null, false, validateToken).ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - private async Task LoginInternal(TokenType tokenType, string token, string email, string password, bool useEmail, bool validateToken) - { - if (LoginState != LoginState.LoggedOut) - await LogoutInternal().ConfigureAwait(false); - LoginState = LoginState.LoggingIn; - - try - { - if (useEmail) - { - var args = new LoginParams { Email = email, Password = password }; - await ApiClient.Login(args).ConfigureAwait(false); - } - else - await ApiClient.Login(tokenType, token).ConfigureAwait(false); - - if (validateToken) - { - try - { - await ApiClient.ValidateToken().ConfigureAwait(false); - } - catch (HttpException ex) - { - throw new ArgumentException("Token validation failed", nameof(token), ex); - } - } - - LoginState = LoginState.LoggedIn; - } - catch (Exception) - { - await LogoutInternal().ConfigureAwait(false); - throw; - } - - await LoggedIn.Raise().ConfigureAwait(false); - } - - public async Task Logout() - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await LogoutInternal().ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - private async Task LogoutInternal() - { - if (LoginState == LoginState.LoggedOut) return; - LoginState = LoginState.LoggingOut; - - await ApiClient.Logout().ConfigureAwait(false); - - _currentUser = null; - - LoginState = LoginState.LoggedOut; - - await LoggedOut.Raise().ConfigureAwait(false); - } - - public async Task> GetConnections() - { - var models = await ApiClient.GetCurrentUserConnections().ConfigureAwait(false); - return models.Select(x => new Connection(x)); - } - - public async Task GetChannel(ulong id) - { - var model = await ApiClient.GetChannel(id).ConfigureAwait(false); - if (model != null) - { - if (model.GuildId != null) - { - var guildModel = await ApiClient.GetGuild(model.GuildId.Value).ConfigureAwait(false); - if (guildModel != null) - { - var guild = new Guild(this, guildModel); - return guild.ToChannel(model); - } - } - else - return new DMChannel(this, model); - } - return null; - } - public async Task> GetDMChannels() - { - var models = await ApiClient.GetCurrentUserDMs().ConfigureAwait(false); - return models.Select(x => new DMChannel(this, x)); - } - - public async Task GetInvite(string inviteIdOrXkcd) - { - var model = await ApiClient.GetInvite(inviteIdOrXkcd).ConfigureAwait(false); - if (model != null) - return new Invite(this, model); - return null; - } - - public async Task GetGuild(ulong id) - { - var model = await ApiClient.GetGuild(id).ConfigureAwait(false); - if (model != null) - return new Guild(this, model); - return null; - } - public async Task GetGuildEmbed(ulong id) - { - var model = await ApiClient.GetGuildEmbed(id).ConfigureAwait(false); - if (model != null) - return new GuildEmbed(model); - return null; - } - public async Task> GetGuilds() - { - var models = await ApiClient.GetCurrentUserGuilds().ConfigureAwait(false); - return models.Select(x => new UserGuild(this, x)); - - } - public async Task CreateGuild(string name, IVoiceRegion region, Stream jpegIcon = null) - { - var args = new CreateGuildParams(); - var model = await ApiClient.CreateGuild(args).ConfigureAwait(false); - return new Guild(this, model); - } - - public async Task GetUser(ulong id) - { - var model = await ApiClient.GetUser(id).ConfigureAwait(false); - if (model != null) - return new PublicUser(this, model); - return null; - } - public async Task GetUser(string username, ushort discriminator) - { - var model = await ApiClient.GetUser(username, discriminator).ConfigureAwait(false); - if (model != null) - return new PublicUser(this, model); - return null; - } - public async Task GetCurrentUser() - { - var user = _currentUser; - if (user == null) - { - var model = await ApiClient.GetCurrentUser().ConfigureAwait(false); - user = new SelfUser(this, model); - _currentUser = user; - } - return user; - } - public async Task> QueryUsers(string query, int limit) - { - var models = await ApiClient.QueryUsers(query, limit).ConfigureAwait(false); - return models.Select(x => new PublicUser(this, x)); - } - - public async Task> GetVoiceRegions() - { - var models = await ApiClient.GetVoiceRegions().ConfigureAwait(false); - return models.Select(x => new VoiceRegion(x)); - } - public async Task GetVoiceRegion(string id) - { - var models = await ApiClient.GetVoiceRegions().ConfigureAwait(false); - return models.Select(x => new VoiceRegion(x)).Where(x => x.Id == id).FirstOrDefault(); - } - - void Dispose(bool disposing) - { - if (!_isDisposed) - _isDisposed = true; - } - public void Dispose() => Dispose(true); - - ConnectionState IDiscordClient.ConnectionState => ConnectionState.Disconnected; - WebSocket.Data.IDataStore IDiscordClient.DataStore => null; - - Task IDiscordClient.Connect() { return Task.FromException(new NotSupportedException("This client does not support websocket connections.")); } - Task IDiscordClient.Disconnect() { return Task.FromException(new NotSupportedException("This client does not support websocket connections.")); } - async Task IDiscordClient.GetChannel(ulong id) - => await GetChannel(id).ConfigureAwait(false); - async Task> IDiscordClient.GetDMChannels() - => await GetDMChannels().ConfigureAwait(false); - async Task> IDiscordClient.GetConnections() - => await GetConnections().ConfigureAwait(false); - async Task IDiscordClient.GetInvite(string inviteIdOrXkcd) - => await GetInvite(inviteIdOrXkcd).ConfigureAwait(false); - async Task IDiscordClient.GetGuild(ulong id) - => await GetGuild(id).ConfigureAwait(false); - async Task> IDiscordClient.GetGuilds() - => await GetGuilds().ConfigureAwait(false); - async Task IDiscordClient.CreateGuild(string name, IVoiceRegion region, Stream jpegIcon) - => await CreateGuild(name, region, jpegIcon).ConfigureAwait(false); - async Task IDiscordClient.GetUser(ulong id) - => await GetUser(id).ConfigureAwait(false); - async Task IDiscordClient.GetUser(string username, ushort discriminator) - => await GetUser(username, discriminator).ConfigureAwait(false); - async Task IDiscordClient.GetCurrentUser() - => await GetCurrentUser().ConfigureAwait(false); - async Task> IDiscordClient.QueryUsers(string query, int limit) - => await QueryUsers(query, limit).ConfigureAwait(false); - async Task> IDiscordClient.GetVoiceRegions() - => await GetVoiceRegions().ConfigureAwait(false); - async Task IDiscordClient.GetVoiceRegion(string id) - => await GetVoiceRegion(id).ConfigureAwait(false); - } -} diff --git a/src/Discord.Net/Rest/Entities/Channels/DMChannel.cs b/src/Discord.Net/Rest/Entities/Channels/DMChannel.cs deleted file mode 100644 index 0efa29da3..000000000 --- a/src/Discord.Net/Rest/Entities/Channels/DMChannel.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Channel; - -namespace Discord.Rest -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class DMChannel : IDMChannel - { - /// - public ulong Id { get; } - internal DiscordClient Discord { get; } - - /// - public User Recipient { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - - internal DMChannel(DiscordClient discord, Model model) - { - Id = model.Id; - Discord = discord; - - Update(model); - } - private void Update(Model model) - { - if (Recipient == null) - Recipient = new PublicUser(Discord, model.Recipient); - else - Recipient.Update(model.Recipient); - } - - /// - public async Task GetUser(ulong id) - { - var currentUser = await Discord.GetCurrentUser().ConfigureAwait(false); - if (id == Recipient.Id) - return Recipient; - else if (id == currentUser.Id) - return currentUser; - else - return null; - } - /// - public async Task> GetUsers() - { - var currentUser = await Discord.GetCurrentUser().ConfigureAwait(false); - return ImmutableArray.Create(currentUser, Recipient); - } - - /// - public async Task> GetMessages(int limit = DiscordConfig.MaxMessagesPerBatch) - { - var args = new GetChannelMessagesParams { Limit = limit }; - var models = await Discord.ApiClient.GetChannelMessages(Id, args).ConfigureAwait(false); - return models.Select(x => new Message(this, x)); - } - /// - public async Task> GetMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - { - var args = new GetChannelMessagesParams { Limit = limit }; - var models = await Discord.ApiClient.GetChannelMessages(Id, args).ConfigureAwait(false); - return models.Select(x => new Message(this, x)); - } - - /// - public async Task SendMessage(string text, bool isTTS = false) - { - var args = new CreateMessageParams { Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.CreateDMMessage(Id, args).ConfigureAwait(false); - return new Message(this, model); - } - /// - public async Task SendFile(string filePath, string text = null, bool isTTS = false) - { - string filename = Path.GetFileName(filePath); - using (var file = File.OpenRead(filePath)) - { - var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.UploadDMFile(Id, file, args).ConfigureAwait(false); - return new Message(this, model); - } - } - /// - public async Task SendFile(Stream stream, string filename, string text = null, bool isTTS = false) - { - var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.UploadDMFile(Id, stream, args).ConfigureAwait(false); - return new Message(this, model); - } - - /// - public async Task DeleteMessages(IEnumerable messages) - { - await Discord.ApiClient.DeleteDMMessages(Id, new DeleteMessagesParam { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); - } - - /// - public async Task TriggerTyping() - { - await Discord.ApiClient.TriggerTypingIndicator(Id).ConfigureAwait(false); - } - - /// - public async Task Close() - { - await Discord.ApiClient.DeleteChannel(Id).ConfigureAwait(false); - } - - /// - public async Task Update() - { - var model = await Discord.ApiClient.GetChannel(Id).ConfigureAwait(false); - Update(model); - } - - /// - public override string ToString() => '@' + Recipient.ToString(); - private string DebuggerDisplay => $"@{Recipient} ({Id}, DM)"; - - IUser IDMChannel.Recipient => Recipient; - IEnumerable IMessageChannel.CachedMessages => Array.Empty(); - - async Task> IChannel.GetUsers() - => await GetUsers().ConfigureAwait(false); - async Task> IChannel.GetUsers(int limit, int offset) - => (await GetUsers().ConfigureAwait(false)).Skip(offset).Take(limit); - async Task IChannel.GetUser(ulong id) - => await GetUser(id).ConfigureAwait(false); - Task IMessageChannel.GetCachedMessage(ulong id) - => Task.FromResult(null); - async Task> IMessageChannel.GetMessages(int limit) - => await GetMessages(limit).ConfigureAwait(false); - async Task> IMessageChannel.GetMessages(ulong fromMessageId, Direction dir, int limit) - => await GetMessages(fromMessageId, dir, limit).ConfigureAwait(false); - async Task IMessageChannel.SendMessage(string text, bool isTTS) - => await SendMessage(text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.SendFile(string filePath, string text, bool isTTS) - => await SendFile(filePath, text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.SendFile(Stream stream, string filename, string text, bool isTTS) - => await SendFile(stream, filename, text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.TriggerTyping() - => await TriggerTyping().ConfigureAwait(false); - } -} diff --git a/src/Discord.Net/Rest/Entities/Channels/GuildChannel.cs b/src/Discord.Net/Rest/Entities/Channels/GuildChannel.cs deleted file mode 100644 index 66e0abe19..000000000 --- a/src/Discord.Net/Rest/Entities/Channels/GuildChannel.cs +++ /dev/null @@ -1,169 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Channel; - -namespace Discord.Rest -{ - public abstract class GuildChannel : IGuildChannel - { - private ConcurrentDictionary _overwrites; - - /// - public ulong Id { get; } - /// Gets the guild this channel is a member of. - public Guild Guild { get; } - - /// - public string Name { get; private set; } - /// - public int Position { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - /// - public IReadOnlyDictionary PermissionOverwrites => _overwrites; - internal DiscordClient Discord => Guild.Discord; - - internal GuildChannel(Guild guild, Model model) - { - Id = model.Id; - Guild = guild; - - Update(model); - } - internal virtual void Update(Model model) - { - Name = model.Name; - Position = model.Position; - - var newOverwrites = new ConcurrentDictionary(); - for (int i = 0; i < model.PermissionOverwrites.Length; i++) - { - var overwrite = model.PermissionOverwrites[i]; - newOverwrites[overwrite.TargetId] = new Overwrite(overwrite); - } - _overwrites = newOverwrites; - } - - public async Task Modify(Action func) - { - if (func != null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildChannelParams(); - func(args); - var model = await Discord.ApiClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); - Update(model); - } - - /// - public OverwritePermissions? GetPermissionOverwrite(IUser user) - { - Overwrite value; - if (_overwrites.TryGetValue(Id, out value)) - return value.Permissions; - return null; - } - /// - public OverwritePermissions? GetPermissionOverwrite(IRole role) - { - Overwrite value; - if (_overwrites.TryGetValue(Id, out value)) - return value.Permissions; - return null; - } - /// Downloads a collection of all invites to this channel. - public async Task> GetInvites() - { - var models = await Discord.ApiClient.GetChannelInvites(Id).ConfigureAwait(false); - return models.Select(x => new InviteMetadata(Discord, x)); - } - - /// - public async Task AddPermissionOverwrite(IUser user, OverwritePermissions perms) - { - var args = new ModifyChannelPermissionsParams { Allow = perms.AllowValue, Deny = perms.DenyValue }; - await Discord.ApiClient.ModifyChannelPermissions(Id, user.Id, args).ConfigureAwait(false); - _overwrites[user.Id] = new Overwrite(new API.Overwrite { Allow = perms.AllowValue, Deny = perms.DenyValue, TargetId = user.Id, TargetType = PermissionTarget.User }); - } - /// - public async Task AddPermissionOverwrite(IRole role, OverwritePermissions perms) - { - var args = new ModifyChannelPermissionsParams { Allow = perms.AllowValue, Deny = perms.DenyValue }; - await Discord.ApiClient.ModifyChannelPermissions(Id, role.Id, args).ConfigureAwait(false); - _overwrites[role.Id] = new Overwrite(new API.Overwrite { Allow = perms.AllowValue, Deny = perms.DenyValue, TargetId = role.Id, TargetType = PermissionTarget.Role }); - } - /// - public async Task RemovePermissionOverwrite(IUser user) - { - await Discord.ApiClient.DeleteChannelPermission(Id, user.Id).ConfigureAwait(false); - - Overwrite value; - _overwrites.TryRemove(user.Id, out value); - } - /// - public async Task RemovePermissionOverwrite(IRole role) - { - await Discord.ApiClient.DeleteChannelPermission(Id, role.Id).ConfigureAwait(false); - - Overwrite value; - _overwrites.TryRemove(role.Id, out value); - } - - /// Creates a new invite to this channel. - /// Time (in seconds) until the invite expires. Set to null to never expire. - /// The max amount of times this invite may be used. Set to null to have unlimited uses. - /// If true, a user accepting this invite will be kicked from the guild after closing their client. - /// If true, creates a human-readable link. Not supported if maxAge is set to null. - public async Task CreateInvite(int? maxAge = 1800, int? maxUses = null, bool isTemporary = false, bool withXkcd = false) - { - var args = new CreateChannelInviteParams - { - MaxAge = maxAge ?? 0, - MaxUses = maxUses ?? 0, - Temporary = isTemporary, - XkcdPass = withXkcd - }; - var model = await Discord.ApiClient.CreateChannelInvite(Id, args).ConfigureAwait(false); - return new InviteMetadata(Discord, model); - } - - /// - public async Task Delete() - { - await Discord.ApiClient.DeleteChannel(Id).ConfigureAwait(false); - } - /// - public async Task Update() - { - var model = await Discord.ApiClient.GetChannel(Id).ConfigureAwait(false); - Update(model); - } - - /// - public override string ToString() => Name; - - protected abstract Task GetUserInternal(ulong id); - protected abstract Task> GetUsersInternal(); - protected abstract Task> GetUsersInternal(int limit, int offset); - - IGuild IGuildChannel.Guild => Guild; - async Task IGuildChannel.CreateInvite(int? maxAge, int? maxUses, bool isTemporary, bool withXkcd) - => await CreateInvite(maxAge, maxUses, isTemporary, withXkcd).ConfigureAwait(false); - async Task> IGuildChannel.GetInvites() - => await GetInvites().ConfigureAwait(false); - async Task> IGuildChannel.GetUsers() - => await GetUsersInternal().ConfigureAwait(false); - async Task> IChannel.GetUsers() - => await GetUsersInternal().ConfigureAwait(false); - async Task> IChannel.GetUsers(int limit, int offset) - => await GetUsersInternal(limit, offset).ConfigureAwait(false); - async Task IGuildChannel.GetUser(ulong id) - => await GetUserInternal(id).ConfigureAwait(false); - async Task IChannel.GetUser(ulong id) - => await GetUserInternal(id).ConfigureAwait(false); - } -} diff --git a/src/Discord.Net/Rest/Entities/Channels/TextChannel.cs b/src/Discord.Net/Rest/Entities/Channels/TextChannel.cs deleted file mode 100644 index 4c171bea2..000000000 --- a/src/Discord.Net/Rest/Entities/Channels/TextChannel.cs +++ /dev/null @@ -1,140 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Channel; - -namespace Discord.Rest -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class TextChannel : GuildChannel, ITextChannel - { - /// - public string Topic { get; private set; } - - /// - public string Mention => MentionUtils.Mention(this); - - internal TextChannel(Guild guild, Model model) - : base(guild, model) - { - } - - internal override void Update(Model model) - { - Topic = model.Topic; - base.Update(model); - } - - public async Task Modify(Action func) - { - if (func != null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyTextChannelParams(); - func(args); - var model = await Discord.ApiClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); - Update(model); - } - - /// Gets a user in this channel with the given id. - public async Task GetUser(ulong id) - { - var user = await Guild.GetUser(id).ConfigureAwait(false); - if (user != null && Permissions.GetValue(Permissions.ResolveChannel(user, this, user.GuildPermissions.RawValue), ChannelPermission.ReadMessages)) - return user; - return null; - } - /// Gets all users in this channel. - public async Task> GetUsers() - { - var users = await Guild.GetUsers().ConfigureAwait(false); - return users.Where(x => Permissions.GetValue(Permissions.ResolveChannel(x, this, x.GuildPermissions.RawValue), ChannelPermission.ReadMessages)); - } - /// Gets a paginated collection of users in this channel. - public async Task> GetUsers(int limit, int offset) - { - var users = await Guild.GetUsers(limit, offset).ConfigureAwait(false); - return users.Where(x => Permissions.GetValue(Permissions.ResolveChannel(x, this, x.GuildPermissions.RawValue), ChannelPermission.ReadMessages)); - } - - /// - public async Task> GetMessages(int limit = DiscordConfig.MaxMessagesPerBatch) - { - var args = new GetChannelMessagesParams { Limit = limit }; - var models = await Discord.ApiClient.GetChannelMessages(Id, args).ConfigureAwait(false); - return models.Select(x => new Message(this, x)); - } - /// - public async Task> GetMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - { - var args = new GetChannelMessagesParams { Limit = limit }; - var models = await Discord.ApiClient.GetChannelMessages(Id, args).ConfigureAwait(false); - return models.Select(x => new Message(this, x)); - } - - /// - public async Task SendMessage(string text, bool isTTS = false) - { - var args = new CreateMessageParams { Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.CreateMessage(Guild.Id, Id, args).ConfigureAwait(false); - return new Message(this, model); - } - /// - public async Task SendFile(string filePath, string text = null, bool isTTS = false) - { - string filename = Path.GetFileName(filePath); - using (var file = File.OpenRead(filePath)) - { - var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.UploadFile(Guild.Id, Id, file, args).ConfigureAwait(false); - return new Message(this, model); - } - } - /// - public async Task SendFile(Stream stream, string filename, string text = null, bool isTTS = false) - { - var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.UploadFile(Guild.Id, Id, stream, args).ConfigureAwait(false); - return new Message(this, model); - } - - /// - public async Task DeleteMessages(IEnumerable messages) - { - await Discord.ApiClient.DeleteMessages(Guild.Id, Id, new DeleteMessagesParam { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); - } - - /// - public async Task TriggerTyping() - { - await Discord.ApiClient.TriggerTypingIndicator(Id).ConfigureAwait(false); - } - - private string DebuggerDisplay => $"{Name} ({Id}, Text)"; - - - protected override Task GetUserInternal(ulong id) => GetUser(id); - protected override Task> GetUsersInternal() => GetUsers(); - protected override Task> GetUsersInternal(int limit, int offset) => GetUsers(limit, offset); - - IEnumerable IMessageChannel.CachedMessages => Array.Empty(); - - Task IMessageChannel.GetCachedMessage(ulong id) - => Task.FromResult(null); - async Task> IMessageChannel.GetMessages(int limit) - => await GetMessages(limit).ConfigureAwait(false); - async Task> IMessageChannel.GetMessages(ulong fromMessageId, Direction dir, int limit) - => await GetMessages(fromMessageId, dir, limit).ConfigureAwait(false); - async Task IMessageChannel.SendMessage(string text, bool isTTS) - => await SendMessage(text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.SendFile(string filePath, string text, bool isTTS) - => await SendFile(filePath, text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.SendFile(Stream stream, string filename, string text, bool isTTS) - => await SendFile(stream, filename, text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.TriggerTyping() - => await TriggerTyping().ConfigureAwait(false); - } -} diff --git a/src/Discord.Net/Rest/Entities/Channels/VoiceChannel.cs b/src/Discord.Net/Rest/Entities/Channels/VoiceChannel.cs deleted file mode 100644 index e105aabd6..000000000 --- a/src/Discord.Net/Rest/Entities/Channels/VoiceChannel.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading.Tasks; -using Model = Discord.API.Channel; - -namespace Discord.Rest -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class VoiceChannel : GuildChannel, IVoiceChannel - { - /// - public int Bitrate { get; private set; } - /// - public int UserLimit { get; private set; } - - internal VoiceChannel(Guild guild, Model model) - : base(guild, model) - { - } - internal override void Update(Model model) - { - base.Update(model); - Bitrate = model.Bitrate; - UserLimit = model.UserLimit; - } - - /// - public async Task Modify(Action func) - { - if (func != null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyVoiceChannelParams(); - func(args); - var model = await Discord.ApiClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); - Update(model); - } - - protected override Task GetUserInternal(ulong id) { throw new NotSupportedException(); } - protected override Task> GetUsersInternal() { throw new NotSupportedException(); } - protected override Task> GetUsersInternal(int limit, int offset) { throw new NotSupportedException(); } - - private string DebuggerDisplay => $"{Name} ({Id}, Voice)"; - } -} diff --git a/src/Discord.Net/Rest/Entities/Guilds/Guild.cs b/src/Discord.Net/Rest/Entities/Guilds/Guild.cs deleted file mode 100644 index 936a0d35c..000000000 --- a/src/Discord.Net/Rest/Entities/Guilds/Guild.cs +++ /dev/null @@ -1,368 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Guild; -using EmbedModel = Discord.API.GuildEmbed; -using RoleModel = Discord.API.Role; -using System.Diagnostics; - -namespace Discord.Rest -{ - /// Represents a Discord guild (called a server in the official client). - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class Guild : IGuild - { - private ConcurrentDictionary _roles; - private string _iconId, _splashId; - - /// - public ulong Id { get; } - internal DiscordClient Discord { get; } - - /// - public string Name { get; private set; } - /// - public int AFKTimeout { get; private set; } - /// - public bool IsEmbeddable { get; private set; } - /// - public int VerificationLevel { get; private set; } - - /// - public ulong? AFKChannelId { get; private set; } - /// - public ulong? EmbedChannelId { get; private set; } - /// - public ulong OwnerId { get; private set; } - /// - public string VoiceRegionId { get; private set; } - /// - public IReadOnlyList Emojis { get; private set; } - /// - public IReadOnlyList Features { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - /// - public string IconUrl => API.CDN.GetGuildIconUrl(Id, _iconId); - /// - public string SplashUrl => API.CDN.GetGuildSplashUrl(Id, _splashId); - /// - public ulong DefaultChannelId => Id; - /// - public Role EveryoneRole => GetRole(Id); - /// Gets a collection of all roles in this guild. - public IEnumerable Roles => _roles?.Select(x => x.Value) ?? Enumerable.Empty(); - - internal Guild(DiscordClient discord, Model model) - { - Id = model.Id; - Discord = discord; - - Update(model); - } - private void Update(Model model) - { - AFKChannelId = model.AFKChannelId; - AFKTimeout = model.AFKTimeout; - EmbedChannelId = model.EmbedChannelId; - IsEmbeddable = model.EmbedEnabled; - Features = model.Features; - _iconId = model.Icon; - Name = model.Name; - OwnerId = model.OwnerId; - VoiceRegionId = model.Region; - _splashId = model.Splash; - VerificationLevel = model.VerificationLevel; - - if (model.Emojis != null) - { - var emojis = ImmutableArray.CreateBuilder(model.Emojis.Length); - for (int i = 0; i < model.Emojis.Length; i++) - emojis.Add(new Emoji(model.Emojis[i])); - Emojis = emojis.ToArray(); - } - else - Emojis = Array.Empty(); - - var roles = new ConcurrentDictionary(1, model.Roles?.Length ?? 0); - if (model.Roles != null) - { - for (int i = 0; i < model.Roles.Length; i++) - roles[model.Roles[i].Id] = new Role(this, model.Roles[i]); - } - _roles = roles; - } - private void Update(EmbedModel model) - { - IsEmbeddable = model.Enabled; - EmbedChannelId = model.ChannelId; - } - private void Update(IEnumerable models) - { - Role role; - foreach (var model in models) - { - if (_roles.TryGetValue(model.Id, out role)) - role.Update(model); - } - } - - /// - public async Task Update() - { - var response = await Discord.ApiClient.GetGuild(Id).ConfigureAwait(false); - Update(response); - } - /// - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildParams(); - func(args); - var model = await Discord.ApiClient.ModifyGuild(Id, args).ConfigureAwait(false); - Update(model); - } - /// - public async Task ModifyEmbed(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildEmbedParams(); - func(args); - var model = await Discord.ApiClient.ModifyGuildEmbed(Id, args).ConfigureAwait(false); - Update(model); - } - /// - public async Task ModifyChannels(IEnumerable args) - { - await Discord.ApiClient.ModifyGuildChannels(Id, args).ConfigureAwait(false); - } - /// - public async Task ModifyRoles(IEnumerable args) - { - var models = await Discord.ApiClient.ModifyGuildRoles(Id, args).ConfigureAwait(false); - Update(models); - } - /// - public async Task Leave() - { - await Discord.ApiClient.LeaveGuild(Id).ConfigureAwait(false); - } - /// - public async Task Delete() - { - await Discord.ApiClient.DeleteGuild(Id).ConfigureAwait(false); - } - - /// - public async Task> GetBans() - { - var models = await Discord.ApiClient.GetGuildBans(Id).ConfigureAwait(false); - return models.Select(x => new PublicUser(Discord, x)); - } - /// - public Task AddBan(IUser user, int pruneDays = 0) => AddBan(user, pruneDays); - /// - public async Task AddBan(ulong userId, int pruneDays = 0) - { - var args = new CreateGuildBanParams() { PruneDays = pruneDays }; - await Discord.ApiClient.CreateGuildBan(Id, userId, args).ConfigureAwait(false); - } - /// - public Task RemoveBan(IUser user) => RemoveBan(user.Id); - /// - public async Task RemoveBan(ulong userId) - { - await Discord.ApiClient.RemoveGuildBan(Id, userId).ConfigureAwait(false); - } - - /// Gets the channel in this guild with the provided id, or null if not found. - public async Task GetChannel(ulong id) - { - var model = await Discord.ApiClient.GetChannel(Id, id).ConfigureAwait(false); - if (model != null) - return ToChannel(model); - return null; - } - /// Gets a collection of all channels in this guild. - public async Task> GetChannels() - { - var models = await Discord.ApiClient.GetGuildChannels(Id).ConfigureAwait(false); - return models.Select(x => ToChannel(x)); - } - /// Creates a new text channel. - public async Task CreateTextChannel(string name) - { - if (name == null) throw new ArgumentNullException(nameof(name)); - - var args = new CreateGuildChannelParams() { Name = name, Type = ChannelType.Text }; - var model = await Discord.ApiClient.CreateGuildChannel(Id, args).ConfigureAwait(false); - return new TextChannel(this, model); - } - /// Creates a new voice channel. - public async Task CreateVoiceChannel(string name) - { - if (name == null) throw new ArgumentNullException(nameof(name)); - - var args = new CreateGuildChannelParams { Name = name, Type = ChannelType.Voice }; - var model = await Discord.ApiClient.CreateGuildChannel(Id, args).ConfigureAwait(false); - return new VoiceChannel(this, model); - } - - /// Gets a collection of all integrations attached to this guild. - public async Task> GetIntegrations() - { - var models = await Discord.ApiClient.GetGuildIntegrations(Id).ConfigureAwait(false); - return models.Select(x => new GuildIntegration(this, x)); - } - /// Creates a new integration for this guild. - public async Task CreateIntegration(ulong id, string type) - { - var args = new CreateGuildIntegrationParams { Id = id, Type = type }; - var model = await Discord.ApiClient.CreateGuildIntegration(Id, args).ConfigureAwait(false); - return new GuildIntegration(this, model); - } - - /// Gets a collection of all invites to this guild. - public async Task> GetInvites() - { - var models = await Discord.ApiClient.GetGuildInvites(Id).ConfigureAwait(false); - return models.Select(x => new InviteMetadata(Discord, x)); - } - /// Creates a new invite to this guild. - public async Task CreateInvite(int? maxAge = 1800, int? maxUses = null, bool isTemporary = false, bool withXkcd = false) - { - if (maxAge <= 0) throw new ArgumentOutOfRangeException(nameof(maxAge)); - if (maxUses <= 0) throw new ArgumentOutOfRangeException(nameof(maxUses)); - - var args = new CreateChannelInviteParams() - { - MaxAge = maxAge ?? 0, - MaxUses = maxUses ?? 0, - Temporary = isTemporary, - XkcdPass = withXkcd - }; - var model = await Discord.ApiClient.CreateChannelInvite(DefaultChannelId, args).ConfigureAwait(false); - return new InviteMetadata(Discord, model); - } - - /// Gets the role in this guild with the provided id, or null if not found. - public Role GetRole(ulong id) - { - Role result = null; - if (_roles?.TryGetValue(id, out result) == true) - return result; - return null; - } - - /// Creates a new role. - public async Task CreateRole(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false) - { - if (name == null) throw new ArgumentNullException(nameof(name)); - - var model = await Discord.ApiClient.CreateGuildRole(Id).ConfigureAwait(false); - var role = new Role(this, model); - - await role.Modify(x => - { - x.Name = name; - x.Permissions = (permissions ?? role.Permissions).RawValue; - x.Color = (color ?? Color.Default).RawValue; - x.Hoist = isHoisted; - }).ConfigureAwait(false); - - return role; - } - - /// Gets a collection of all users in this guild. - public async Task> GetUsers() - { - var args = new GetGuildMembersParams(); - var models = await Discord.ApiClient.GetGuildMembers(Id, args).ConfigureAwait(false); - return models.Select(x => new GuildUser(this, x)); - } - /// Gets a paged collection of all users in this guild. - public async Task> GetUsers(int limit, int offset) - { - var args = new GetGuildMembersParams { Limit = limit, Offset = offset }; - var models = await Discord.ApiClient.GetGuildMembers(Id, args).ConfigureAwait(false); - return models.Select(x => new GuildUser(this, x)); - } - /// Gets the user in this guild with the provided id, or null if not found. - public async Task GetUser(ulong id) - { - var model = await Discord.ApiClient.GetGuildMember(Id, id).ConfigureAwait(false); - if (model != null) - return new GuildUser(this, model); - return null; - } - /// Gets a the current user. - public async Task GetCurrentUser() - { - var currentUser = await Discord.GetCurrentUser().ConfigureAwait(false); - return await GetUser(currentUser.Id).ConfigureAwait(false); - } - public async Task PruneUsers(int days = 30, bool simulate = false) - { - var args = new GuildPruneParams() { Days = days }; - GetGuildPruneCountResponse model; - if (simulate) - model = await Discord.ApiClient.GetGuildPruneCount(Id, args).ConfigureAwait(false); - else - model = await Discord.ApiClient.BeginGuildPrune(Id, args).ConfigureAwait(false); - return model.Pruned; - } - - internal GuildChannel ToChannel(API.Channel model) - { - switch (model.Type) - { - case ChannelType.Text: - default: - return new TextChannel(this, model); - case ChannelType.Voice: - return new VoiceChannel(this, model); - } - } - - public override string ToString() => Name; - private string DebuggerDisplay => $"{Name} ({Id})"; - - IEnumerable IGuild.Emojis => Emojis; - ulong IGuild.EveryoneRoleId => EveryoneRole.Id; - IEnumerable IGuild.Features => Features; - - async Task> IGuild.GetBans() - => await GetBans().ConfigureAwait(false); - async Task IGuild.GetChannel(ulong id) - => await GetChannel(id).ConfigureAwait(false); - async Task> IGuild.GetChannels() - => await GetChannels().ConfigureAwait(false); - async Task IGuild.CreateInvite(int? maxAge, int? maxUses, bool isTemporary, bool withXkcd) - => await CreateInvite(maxAge, maxUses, isTemporary, withXkcd).ConfigureAwait(false); - async Task IGuild.CreateRole(string name, GuildPermissions? permissions, Color? color, bool isHoisted) - => await CreateRole(name, permissions, color, isHoisted).ConfigureAwait(false); - async Task IGuild.CreateTextChannel(string name) - => await CreateTextChannel(name).ConfigureAwait(false); - async Task IGuild.CreateVoiceChannel(string name) - => await CreateVoiceChannel(name).ConfigureAwait(false); - async Task> IGuild.GetInvites() - => await GetInvites().ConfigureAwait(false); - Task IGuild.GetRole(ulong id) - => Task.FromResult(GetRole(id)); - Task> IGuild.GetRoles() - => Task.FromResult>(Roles); - async Task IGuild.GetUser(ulong id) - => await GetUser(id).ConfigureAwait(false); - async Task IGuild.GetCurrentUser() - => await GetCurrentUser().ConfigureAwait(false); - async Task> IGuild.GetUsers() - => await GetUsers().ConfigureAwait(false); - } -} diff --git a/src/Discord.Net/Rest/Entities/Guilds/GuildEmbed.cs b/src/Discord.Net/Rest/Entities/Guilds/GuildEmbed.cs deleted file mode 100644 index d7f5a3831..000000000 --- a/src/Discord.Net/Rest/Entities/Guilds/GuildEmbed.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Diagnostics; -using Model = Discord.API.GuildEmbed; - -namespace Discord -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class GuildEmbed : IGuildEmbed - { - /// - public ulong Id { get; } - /// - public bool IsEnabled { get; private set; } - /// - public ulong? ChannelId { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - - internal GuildEmbed(Model model) - { - Update(model); - } - - private void Update(Model model) - { - ChannelId = model.ChannelId; - IsEnabled = model.Enabled; - } - - public override string ToString() => Id.ToString(); - private string DebuggerDisplay => $"{Id}{(IsEnabled ? " (Enabled)" : "")}"; - } -} diff --git a/src/Discord.Net/Rest/Entities/Message.cs b/src/Discord.Net/Rest/Entities/Message.cs deleted file mode 100644 index 319394214..000000000 --- a/src/Discord.Net/Rest/Entities/Message.cs +++ /dev/null @@ -1,148 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.Threading.Tasks; -using Model = Discord.API.Message; - -namespace Discord.Rest -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class Message : IMessage - { - /// - public ulong Id { get; } - - /// - public DateTime? EditedTimestamp { get; private set; } - /// - public bool IsTTS { get; private set; } - /// - public string RawText { get; private set; } - /// - public string Text { get; private set; } - /// - public DateTime Timestamp { get; private set; } - - /// - public IMessageChannel Channel { get; } - /// - public IUser Author { get; } - - /// - public IReadOnlyList Attachments { get; private set; } - /// - public IReadOnlyList Embeds { get; private set; } - /// - public IReadOnlyList MentionedUsers { get; private set; } - /// - public IReadOnlyList MentionedChannelIds { get; private set; } - /// - public IReadOnlyList MentionedRoleIds { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - internal DiscordClient Discord => (Channel as TextChannel)?.Discord ?? (Channel as DMChannel).Discord; - - internal Message(IMessageChannel channel, Model model) - { - Id = model.Id; - Channel = channel; - Author = new PublicUser(Discord, model.Author); - - Update(model); - } - private void Update(Model model) - { - var guildChannel = Channel as GuildChannel; - var guild = guildChannel?.Guild; - var discord = Discord; - - IsTTS = model.IsTextToSpeech; - Timestamp = model.Timestamp; - EditedTimestamp = model.EditedTimestamp; - RawText = model.Content; - - if (model.Attachments.Length > 0) - { - var attachments = new Attachment[model.Attachments.Length]; - for (int i = 0; i < attachments.Length; i++) - attachments[i] = new Attachment(model.Attachments[i]); - Attachments = ImmutableArray.Create(attachments); - } - else - Attachments = Array.Empty(); - - if (model.Embeds.Length > 0) - { - var embeds = new Embed[model.Attachments.Length]; - for (int i = 0; i < embeds.Length; i++) - embeds[i] = new Embed(model.Embeds[i]); - Embeds = ImmutableArray.Create(embeds); - } - else - Embeds = Array.Empty(); - - if (guildChannel != null && model.Mentions.Length > 0) - { - var mentions = new PublicUser[model.Mentions.Length]; - for (int i = 0; i < model.Mentions.Length; i++) - mentions[i] = new PublicUser(discord, model.Mentions[i]); - MentionedUsers = ImmutableArray.Create(mentions); - } - else - MentionedUsers = Array.Empty(); - - if (guildChannel != null) - { - MentionedChannelIds = MentionUtils.GetChannelMentions(model.Content); - - var mentionedRoleIds = MentionUtils.GetRoleMentions(model.Content); - if (model.IsMentioningEveryone) - mentionedRoleIds = mentionedRoleIds.Add(guildChannel.Guild.EveryoneRole.Id); - MentionedRoleIds = mentionedRoleIds; - } - else - { - MentionedChannelIds = Array.Empty(); - MentionedRoleIds = Array.Empty(); - } - - Text = MentionUtils.CleanUserMentions(model.Content, model.Mentions); - } - - /// - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyMessageParams(); - func(args); - var guildChannel = Channel as GuildChannel; - - Model model; - if (guildChannel != null) - model = await Discord.ApiClient.ModifyMessage(guildChannel.Guild.Id, Channel.Id, Id, args).ConfigureAwait(false); - else - model = await Discord.ApiClient.ModifyDMMessage(Channel.Id, Id, args).ConfigureAwait(false); - Update(model); - } - - /// - public async Task Delete() - { - var guildChannel = Channel as GuildChannel; - if (guildChannel != null) - await Discord.ApiClient.DeleteMessage(guildChannel.Id, Channel.Id, Id).ConfigureAwait(false); - else - await Discord.ApiClient.DeleteDMMessage(Channel.Id, Id).ConfigureAwait(false); - } - - public override string ToString() => Text; - private string DebuggerDisplay => $"{Author}: {Text}{(Attachments.Count > 0 ? $" [{Attachments.Count} Attachments]" : "")}"; - - IUser IMessage.Author => Author; - IReadOnlyList IMessage.MentionedUsers => MentionedUsers; - } -} diff --git a/src/Discord.Net/Rest/Entities/Role.cs b/src/Discord.Net/Rest/Entities/Role.cs deleted file mode 100644 index 20ed0940e..000000000 --- a/src/Discord.Net/Rest/Entities/Role.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Role; - -namespace Discord.Rest -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class Role : IRole, IMentionable - { - /// - public ulong Id { get; } - /// Returns the guild this role belongs to. - public Guild Guild { get; } - - /// - public Color Color { get; private set; } - /// - public bool IsHoisted { get; private set; } - /// - public bool IsManaged { get; private set; } - /// - public string Name { get; private set; } - /// - public GuildPermissions Permissions { get; private set; } - /// - public int Position { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - /// - public bool IsEveryone => Id == Guild.Id; - /// - public string Mention => MentionUtils.Mention(this); - internal DiscordClient Discord => Guild.Discord; - - internal Role(Guild guild, Model model) - { - Id = model.Id; - Guild = guild; - - Update(model); - } - internal void Update(Model model) - { - Name = model.Name; - IsHoisted = model.Hoist.Value; - IsManaged = model.Managed.Value; - Position = model.Position.Value; - Color = new Color(model.Color.Value); - Permissions = new GuildPermissions(model.Permissions.Value); - } - /// Modifies the properties of this role. - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildRoleParams(); - func(args); - var response = await Discord.ApiClient.ModifyGuildRole(Guild.Id, Id, args).ConfigureAwait(false); - Update(response); - } - /// Deletes this message. - public async Task Delete() - => await Discord.ApiClient.DeleteGuildRole(Guild.Id, Id).ConfigureAwait(false); - - /// - public override string ToString() => Name; - private string DebuggerDisplay => $"{Name} ({Id})"; - - ulong IRole.GuildId => Guild.Id; - - async Task> IRole.GetUsers() - { - //TODO: Rethink this, it isn't paginated or anything... - var models = await Discord.ApiClient.GetGuildMembers(Guild.Id, new GetGuildMembersParams()).ConfigureAwait(false); - return models.Where(x => x.Roles.Contains(Id)).Select(x => new GuildUser(Guild, x)); - } - } -} diff --git a/src/Discord.Net/Rest/Entities/Users/GuildUser.cs b/src/Discord.Net/Rest/Entities/Users/GuildUser.cs deleted file mode 100644 index 33d100255..000000000 --- a/src/Discord.Net/Rest/Entities/Users/GuildUser.cs +++ /dev/null @@ -1,112 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.GuildMember; - -namespace Discord.Rest -{ - public class GuildUser : User, IGuildUser - { - private ImmutableArray _roles; - - public Guild Guild { get; } - - /// - public bool IsDeaf { get; private set; } - /// - public bool IsMute { get; private set; } - /// - public DateTime JoinedAt { get; private set; } - /// - public string Nickname { get; private set; } - - /// - public GuildPermissions GuildPermissions { get; private set; } - - /// - public IReadOnlyList Roles => _roles; - internal override DiscordClient Discord => Guild.Discord; - - internal GuildUser(Guild guild, Model model) - : base(model.User) - { - Guild = guild; - - Update(model); - } - internal void Update(Model model) - { - IsDeaf = model.Deaf; - IsMute = model.Mute; - JoinedAt = model.JoinedAt.Value; - Nickname = model.Nick; - - var roles = ImmutableArray.CreateBuilder(model.Roles.Length + 1); - roles.Add(Guild.EveryoneRole); - for (int i = 0; i < model.Roles.Length; i++) - roles.Add(Guild.GetRole(model.Roles[i])); - _roles = roles.ToImmutable(); - - GuildPermissions = new GuildPermissions(Permissions.ResolveGuild(this)); - } - - public async Task Update() - { - var model = await Discord.ApiClient.GetGuildMember(Guild.Id, Id).ConfigureAwait(false); - Update(model); - } - - public async Task Kick() - { - await Discord.ApiClient.RemoveGuildMember(Guild.Id, Id).ConfigureAwait(false); - } - - public ChannelPermissions GetPermissions(IGuildChannel channel) - { - if (channel == null) throw new ArgumentNullException(nameof(channel)); - return new ChannelPermissions(Permissions.ResolveChannel(this, channel, GuildPermissions.RawValue)); - } - - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildMemberParams(); - func(args); - - bool isCurrentUser = (await Discord.GetCurrentUser().ConfigureAwait(false)).Id == Id; - if (isCurrentUser && args.Nickname.IsSpecified) - { - var nickArgs = new ModifyCurrentUserNickParams { Nickname = args.Nickname.Value ?? "" }; - await Discord.ApiClient.ModifyCurrentUserNick(Guild.Id, nickArgs).ConfigureAwait(false); - args.Nickname = new API.Optional(); //Remove - } - - if (!isCurrentUser || args.Deaf.IsSpecified || args.Mute.IsSpecified || args.Roles.IsSpecified) - { - await Discord.ApiClient.ModifyGuildMember(Guild.Id, Id, args).ConfigureAwait(false); - if (args.Deaf.IsSpecified) - IsDeaf = args.Deaf.Value; - if (args.Mute.IsSpecified) - IsMute = args.Mute.Value; - if (args.Nickname.IsSpecified) - Nickname = args.Nickname.Value ?? ""; - if (args.Roles.IsSpecified) - _roles = args.Roles.Value.Select(x => Guild.GetRole(x)).Where(x => x != null).ToImmutableArray(); - } - } - - - IGuild IGuildUser.Guild => Guild; - IReadOnlyList IGuildUser.Roles => Roles; - IVoiceChannel IGuildUser.VoiceChannel => null; - - GuildPermissions IGuildUser.GetGuildPermissions() - => GuildPermissions; - ChannelPermissions IGuildUser.GetPermissions(IGuildChannel channel) - => GetPermissions(channel); - } -} diff --git a/src/Discord.Net/Rest/Entities/Users/PublicUser.cs b/src/Discord.Net/Rest/Entities/Users/PublicUser.cs deleted file mode 100644 index 20f9a3919..000000000 --- a/src/Discord.Net/Rest/Entities/Users/PublicUser.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Model = Discord.API.User; - -namespace Discord.Rest -{ - public class PublicUser : User - { - internal override DiscordClient Discord { get; } - - internal PublicUser(DiscordClient discord, Model model) - : base(model) - { - Discord = discord; - } - } -} diff --git a/src/Discord.Net/Rest/Entities/Users/SelfUser.cs b/src/Discord.Net/Rest/Entities/Users/SelfUser.cs deleted file mode 100644 index a821b369b..000000000 --- a/src/Discord.Net/Rest/Entities/Users/SelfUser.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Threading.Tasks; -using Model = Discord.API.User; - -namespace Discord.Rest -{ - public class SelfUser : User, ISelfUser - { - internal override DiscordClient Discord { get; } - - /// - public string Email { get; private set; } - /// - public bool IsVerified { get; private set; } - - internal SelfUser(DiscordClient discord, Model model) - : base(model) - { - Discord = discord; - } - internal override void Update(Model model) - { - base.Update(model); - - Email = model.Email; - IsVerified = model.IsVerified; - } - - /// - public async Task Update() - { - var model = await Discord.ApiClient.GetCurrentUser().ConfigureAwait(false); - Update(model); - } - - /// - public async Task Modify(Action func) - { - if (func != null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyCurrentUserParams(); - func(args); - var model = await Discord.ApiClient.ModifyCurrentUser(args).ConfigureAwait(false); - Update(model); - } - } -} diff --git a/src/Discord.Net/Rest/Entities/Users/User.cs b/src/Discord.Net/Rest/Entities/Users/User.cs deleted file mode 100644 index 26754fc18..000000000 --- a/src/Discord.Net/Rest/Entities/Users/User.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using Model = Discord.API.User; - -namespace Discord.Rest -{ - [DebuggerDisplay("{DebuggerDisplay,nq}")] - public abstract class User : IUser - { - private string _avatarId; - - /// - public ulong Id { get; } - internal abstract DiscordClient Discord { get; } - - /// - public ushort Discriminator { get; private set; } - /// - public bool IsBot { get; private set; } - /// - public string Username { get; private set; } - - /// - public string AvatarUrl => API.CDN.GetUserAvatarUrl(Id, _avatarId); - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - /// - public string Mention => MentionUtils.Mention(this, false); - /// - public string NicknameMention => MentionUtils.Mention(this, true); - - internal User(Model model) - { - Id = model.Id; - - Update(model); - } - internal virtual void Update(Model model) - { - _avatarId = model.Avatar; - Discriminator = model.Discriminator; - IsBot = model.Bot; - Username = model.Username; - } - - protected virtual async Task CreateDMChannelInternal() - { - var args = new CreateDMChannelParams { RecipientId = Id }; - var model = await Discord.ApiClient.CreateDMChannel(args).ConfigureAwait(false); - - return new DMChannel(Discord, model); - } - - public override string ToString() => $"{Username}#{Discriminator}"; - private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id})"; - - /// - Game? IUser.CurrentGame => null; - /// - UserStatus IUser.Status => UserStatus.Unknown; - - /// - async Task IUser.CreateDMChannel() - => await CreateDMChannelInternal().ConfigureAwait(false); - } -} diff --git a/src/Discord.Net/Utilities/AsyncEvent.cs b/src/Discord.Net/Utilities/AsyncEvent.cs new file mode 100644 index 000000000..0a4d55ed7 --- /dev/null +++ b/src/Discord.Net/Utilities/AsyncEvent.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; + +namespace Discord +{ + internal class AsyncEvent + { + private readonly object _subLock = new object(); + internal ImmutableArray _subscriptions; + + public IReadOnlyList Subscriptions => _subscriptions; + + public AsyncEvent() + { + _subscriptions = ImmutableArray.Create(); + } + + public void Add(T subscriber) + { + lock (_subLock) + _subscriptions = _subscriptions.Add(subscriber); + } + public void Remove(T subscriber) + { + lock (_subLock) + _subscriptions = _subscriptions.Remove(subscriber); + } + } + + internal static class EventExtensions + { + public static async Task InvokeAsync(this AsyncEvent> eventHandler) + { + var subscribers = eventHandler.Subscriptions; + if (subscribers.Count > 0) + { + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke().ConfigureAwait(false); + } + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T arg) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg).ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T1 arg1, T2 arg2) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg1, arg2).ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T1 arg1, T2 arg2, T3 arg3) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg1, arg2, arg3).ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T1 arg1, T2 arg2, T3 arg3, T4 arg4) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg1, arg2, arg3, arg4).ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg1, arg2, arg3, arg4, arg5).ConfigureAwait(false); + } + } +} diff --git a/src/Discord.Net/ConcurrentHashSet.cs b/src/Discord.Net/Utilities/ConcurrentHashSet.cs similarity index 99% rename from src/Discord.Net/ConcurrentHashSet.cs rename to src/Discord.Net/Utilities/ConcurrentHashSet.cs index 18572cfcf..1805649a9 100644 --- a/src/Discord.Net/ConcurrentHashSet.cs +++ b/src/Discord.Net/Utilities/ConcurrentHashSet.cs @@ -10,7 +10,7 @@ namespace Discord //Based on https://github.com/dotnet/corefx/blob/master/src/System.Collections.Concurrent/src/System/Collections/Concurrent/ConcurrentDictionary.cs //Copyright (c) .NET Foundation and Contributors [DebuggerDisplay("Count = {Count}")] - internal class ConcurrentHashSet : IEnumerable + internal class ConcurrentHashSet : IReadOnlyCollection { private sealed class Tables { diff --git a/src/Discord.Net/Utilities/DateTimeUtils.cs b/src/Discord.Net/Utilities/DateTimeUtils.cs new file mode 100644 index 000000000..b3496520c --- /dev/null +++ b/src/Discord.Net/Utilities/DateTimeUtils.cs @@ -0,0 +1,15 @@ +using System; + +namespace Discord +{ + internal static class DateTimeUtils + { + public static DateTimeOffset FromSnowflake(ulong value) + => DateTimeOffset.FromUnixTimeMilliseconds((long)((value >> 22) + 1420070400000UL)); + + public static DateTimeOffset FromTicks(long ticks) + => new DateTimeOffset(ticks, TimeSpan.Zero); + public static DateTimeOffset? FromTicks(long? ticks) + => ticks != null ? new DateTimeOffset(ticks.Value, TimeSpan.Zero) : (DateTimeOffset?)null; + } +} diff --git a/src/Discord.Net/MentionUtils.cs b/src/Discord.Net/Utilities/MentionUtils.cs similarity index 59% rename from src/Discord.Net/MentionUtils.cs rename to src/Discord.Net/Utilities/MentionUtils.cs index 7d37ffc58..f348d4962 100644 --- a/src/Discord.Net/MentionUtils.cs +++ b/src/Discord.Net/Utilities/MentionUtils.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace Discord { @@ -19,6 +20,14 @@ namespace Discord /// Parses a provided user mention string. public static ulong ParseUser(string mentionText) + { + ulong id; + if (TryParseUser(mentionText, out id)) + return id; + throw new ArgumentException("Invalid mention format", nameof(mentionText)); + } + /// Tries to parse a provided user mention string. + public static bool TryParseUser(string mentionText, out ulong userId) { mentionText = mentionText.Trim(); if (mentionText.Length >= 3 && mentionText[0] == '<' && mentionText[1] == '@' && mentionText[mentionText.Length - 1] == '>') @@ -27,48 +36,65 @@ namespace Discord mentionText = mentionText.Substring(3, mentionText.Length - 4); //<@!123> else mentionText = mentionText.Substring(2, mentionText.Length - 3); //<@123> - - ulong id; - if (ulong.TryParse(mentionText, NumberStyles.None, CultureInfo.InvariantCulture, out id)) - return id; + + if (ulong.TryParse(mentionText, NumberStyles.None, CultureInfo.InvariantCulture, out userId)) + return true; } - throw new ArgumentException("Invalid mention format", nameof(mentionText)); + userId = 0; + return false; } + /// Parses a provided channel mention string. public static ulong ParseChannel(string mentionText) + { + ulong id; + if (TryParseChannel(mentionText, out id)) + return id; + throw new ArgumentException("Invalid mention format", nameof(mentionText)); + } + /// Tries to parse a provided channel mention string. + public static bool TryParseChannel(string mentionText, out ulong channelId) { mentionText = mentionText.Trim(); if (mentionText.Length >= 3 && mentionText[0] == '<' && mentionText[1] == '#' && mentionText[mentionText.Length - 1] == '>') { mentionText = mentionText.Substring(2, mentionText.Length - 3); //<#123> - - ulong id; - if (ulong.TryParse(mentionText, NumberStyles.None, CultureInfo.InvariantCulture, out id)) - return id; + + if (ulong.TryParse(mentionText, NumberStyles.None, CultureInfo.InvariantCulture, out channelId)) + return true; } - throw new ArgumentException("Invalid mention format", nameof(mentionText)); + channelId = 0; + return false; } /// Parses a provided role mention string. public static ulong ParseRole(string mentionText) + { + ulong id; + if (TryParseRole(mentionText, out id)) + return id; + throw new ArgumentException("Invalid mention format", nameof(mentionText)); + } + /// Tries to parse a provided role mention string. + public static bool TryParseRole(string mentionText, out ulong roleId) { mentionText = mentionText.Trim(); if (mentionText.Length >= 4 && mentionText[0] == '<' && mentionText[1] == '@' && mentionText[2] == '&' && mentionText[mentionText.Length - 1] == '>') { mentionText = mentionText.Substring(3, mentionText.Length - 4); //<@&123> - - ulong id; - if (ulong.TryParse(mentionText, NumberStyles.None, CultureInfo.InvariantCulture, out id)) - return id; + + if (ulong.TryParse(mentionText, NumberStyles.None, CultureInfo.InvariantCulture, out roleId)) + return true; } - throw new ArgumentException("Invalid mention format", nameof(mentionText)); + roleId = 0; + return false; } /// Gets the ids of all users mentioned in a provided text. - public static IImmutableList GetUserMentions(string text) => GetMentions(text, _userRegex).ToImmutableArray(); + public static ImmutableArray GetUserMentions(string text) => GetMentions(text, _userRegex).ToImmutable(); /// Gets the ids of all channels mentioned in a provided text. - public static IImmutableList GetChannelMentions(string text) => GetMentions(text, _channelRegex).ToImmutableArray(); + public static ImmutableArray GetChannelMentions(string text) => GetMentions(text, _channelRegex).ToImmutable(); /// Gets the ids of all roles mentioned in a provided text. - public static IImmutableList GetRoleMentions(string text) => GetMentions(text, _roleRegex).ToImmutableArray(); + public static ImmutableArray GetRoleMentions(string text) => GetMentions(text, _roleRegex).ToImmutable(); private static ImmutableArray.Builder GetMentions(string text, Regex regex) { var matches = regex.Matches(text); @@ -82,7 +108,7 @@ namespace Discord return builder; } - internal static string CleanUserMentions(string text, API.User[] mentions) + /*internal static string CleanUserMentions(string text, ImmutableArray mentions) { return _userRegex.Replace(text, new MatchEvaluator(e => { @@ -98,58 +124,71 @@ namespace Discord } return e.Value; })); - } - internal static string CleanUserMentions(string text, IReadOnlyDictionary users, ImmutableArray.Builder mentions = null) - where T : IGuildUser + }*/ + internal static string CleanUserMentions(string text, IMessageChannel channel, IReadOnlyCollection fallbackUsers, ImmutableArray.Builder mentions = null) { - return _channelRegex.Replace(text, new MatchEvaluator(e => + return _userRegex.Replace(text, new MatchEvaluator(e => { ulong id; if (ulong.TryParse(e.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out id)) { - T user; - if (users.TryGetValue(id, out user)) + IUser user = null; + if (channel != null) + user = channel.GetUserAsync(id).GetAwaiter().GetResult() as IUser; + if (user == null) + { + foreach (var fallbackUser in fallbackUsers) + { + if (fallbackUser.Id == id) + { + user = fallbackUser; + break; + } + } + } + if (user != null) { - if (users != null) - mentions.Add(user); - if (e.Value[2] == '!' && user.Nickname != null) - return '@' + user.Nickname; - else - return '@' + user.Username; + mentions.Add(user); + + if (e.Value[2] == '!') + { + var guildUser = user as IGuildUser; + if (guildUser != null && guildUser.Nickname != null) + return '@' + guildUser.Nickname; + } + return '@' + user.Username; } } return e.Value; })); } - internal static string CleanChannelMentions(string text, IReadOnlyDictionary channels, ImmutableArray.Builder mentions = null) - where T : IGuildChannel + internal static string CleanChannelMentions(string text, IGuild guild, ImmutableArray.Builder mentions = null) { return _channelRegex.Replace(text, new MatchEvaluator(e => { ulong id; if (ulong.TryParse(e.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out id)) { - T channel; - if (channels.TryGetValue(id, out channel)) + var channel = guild.GetChannelAsync(id).GetAwaiter().GetResult() as IGuildChannel; + if (channel != null) { - if (channels != null) - mentions.Add(channel); + if (mentions != null) + mentions.Add(channel.Id); return '#' + channel.Name; } } return e.Value; })); } - internal static string CleanRoleMentions(string text, IReadOnlyDictionary roles, ImmutableArray.Builder mentions = null) - where T : IRole + internal static string CleanRoleMentions(string text, IGuild guild, ImmutableArray.Builder mentions = null) { - return _channelRegex.Replace(text, new MatchEvaluator(e => + return _roleRegex.Replace(text, new MatchEvaluator(e => { ulong id; if (ulong.TryParse(e.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out id)) { - T role; - if (roles.TryGetValue(id, out role)) + var role = guild.GetRole(id); + if (role != null) { if (mentions != null) mentions.Add(role); diff --git a/src/Discord.Net/API/Optional.cs b/src/Discord.Net/Utilities/Optional.cs similarity index 78% rename from src/Discord.Net/API/Optional.cs rename to src/Discord.Net/Utilities/Optional.cs index b828608b2..e2d55cf7f 100644 --- a/src/Discord.Net/API/Optional.cs +++ b/src/Discord.Net/Utilities/Optional.cs @@ -1,12 +1,13 @@ using System; using System.Diagnostics; -namespace Discord.API +namespace Discord { //Based on https://github.com/dotnet/coreclr/blob/master/src/mscorlib/src/System/Nullable.cs [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public struct Optional : IOptional + public struct Optional { + public static Optional Unspecified => default(Optional); private readonly T _value; /// Gets the value for this paramter. @@ -28,7 +29,7 @@ namespace Discord.API _value = value; IsSpecified = true; } - + public T GetValueOrDefault() => _value; public T GetValueOrDefault(T defaultValue) => IsSpecified ? _value : defaultValue; @@ -41,11 +42,14 @@ namespace Discord.API public override int GetHashCode() => IsSpecified ? _value.GetHashCode() : 0; public override string ToString() => IsSpecified ? _value?.ToString() : null; - private string DebuggerDisplay => IsSpecified ? _value.ToString() : ""; + private string DebuggerDisplay => IsSpecified ? (_value?.ToString() ?? "") : ""; public static implicit operator Optional(T value) => new Optional(value); public static explicit operator T(Optional value) => value.Value; - - object IOptional.Value => Value; + } + public static class Optional + { + public static Optional Create() => Optional.Unspecified; + public static Optional Create(T value) => new Optional(value); } } diff --git a/src/Discord.Net/Preconditions.cs b/src/Discord.Net/Utilities/Preconditions.cs similarity index 100% rename from src/Discord.Net/Preconditions.cs rename to src/Discord.Net/Utilities/Preconditions.cs diff --git a/src/Discord.Net/WebSocket/Data/DataStoreProvider.cs b/src/Discord.Net/WebSocket/Data/DataStoreProvider.cs deleted file mode 100644 index 0b1c78317..000000000 --- a/src/Discord.Net/WebSocket/Data/DataStoreProvider.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Discord.WebSocket.Data -{ - public delegate IDataStore DataStoreProvider(int shardId, int totalShards, int guildCount, int dmCount); -} diff --git a/src/Discord.Net/WebSocket/Data/DefaultDataStore.cs b/src/Discord.Net/WebSocket/Data/DefaultDataStore.cs deleted file mode 100644 index 28a4ca0d1..000000000 --- a/src/Discord.Net/WebSocket/Data/DefaultDataStore.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; - -namespace Discord.WebSocket.Data -{ - public class DefaultDataStore : IDataStore - { - private const double AverageChannelsPerGuild = 10.22; //Source: Googie2149 - private const double AverageUsersPerGuild = 47.78; //Source: Googie2149 - private const double CollectionMultiplier = 1.05; //Add buffer to handle growth - private const double CollectionConcurrencyLevel = 1; //WebSocket updater/event handler. //TODO: Needs profiling, increase to 2? - - private ConcurrentDictionary _channels; - private ConcurrentDictionary _guilds; - private ConcurrentDictionary _roles; - private ConcurrentDictionary _users; - - public IEnumerable Channels => _channels.Select(x => x.Value); - public IEnumerable Guilds => _guilds.Select(x => x.Value); - public IEnumerable Roles => _roles.Select(x => x.Value); - public IEnumerable Users => _users.Select(x => x.Value); - - public DefaultDataStore(int guildCount, int dmChannelCount) - { - _channels = new ConcurrentDictionary(1, (int)((guildCount * AverageChannelsPerGuild + dmChannelCount) * CollectionMultiplier)); - _guilds = new ConcurrentDictionary(1, (int)(guildCount * CollectionMultiplier)); - _users = new ConcurrentDictionary(1, (int)(guildCount * AverageUsersPerGuild * CollectionMultiplier)); - } - - public Channel GetChannel(ulong id) - { - Channel channel; - if (_channels.TryGetValue(id, out channel)) - return channel; - return null; - } - public void AddChannel(Channel channel) - { - _channels[channel.Id] = channel; - } - public Channel RemoveChannel(ulong id) - { - Channel channel; - if (_channels.TryRemove(id, out channel)) - return channel; - return null; - } - - public Guild GetGuild(ulong id) - { - Guild guild; - if (_guilds.TryGetValue(id, out guild)) - return guild; - return null; - } - public void AddGuild(Guild guild) - { - _guilds[guild.Id] = guild; - } - public Guild RemoveGuild(ulong id) - { - Guild guild; - if (_guilds.TryRemove(id, out guild)) - return guild; - return null; - } - - public Role GetRole(ulong id) - { - Role role; - if (_roles.TryGetValue(id, out role)) - return role; - return null; - } - public void AddRole(Role role) - { - _roles[role.Id] = role; - } - public Role RemoveRole(ulong id) - { - Role role; - if (_roles.TryRemove(id, out role)) - return role; - return null; - } - - public User GetUser(ulong id) - { - User user; - if (_users.TryGetValue(id, out user)) - return user; - return null; - } - public void AddUser(User user) - { - _users[user.Id] = user; - } - public User RemoveUser(ulong id) - { - User user; - if (_users.TryRemove(id, out user)) - return user; - return null; - } - } -} diff --git a/src/Discord.Net/WebSocket/Data/IDataStore.cs b/src/Discord.Net/WebSocket/Data/IDataStore.cs deleted file mode 100644 index b980d13d5..000000000 --- a/src/Discord.Net/WebSocket/Data/IDataStore.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; - -namespace Discord.WebSocket.Data -{ - public interface IDataStore - { - IEnumerable Channels { get; } - IEnumerable Guilds { get; } - IEnumerable Users { get; } - - Channel GetChannel(ulong id); - void AddChannel(Channel channel); - Channel RemoveChannel(ulong id); - - Guild GetGuild(ulong id); - void AddGuild(Guild guild); - Guild RemoveGuild(ulong id); - - User GetUser(ulong id); - void AddUser(User user); - User RemoveUser(ulong id); - } -} diff --git a/src/Discord.Net/WebSocket/Data/SharedDataStore.cs b/src/Discord.Net/WebSocket/Data/SharedDataStore.cs deleted file mode 100644 index 8512a2679..000000000 --- a/src/Discord.Net/WebSocket/Data/SharedDataStore.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Discord.WebSocket.Data -{ - //TODO: Implement - /*public class SharedDataStore - { - }*/ -} diff --git a/src/Discord.Net/WebSocket/DiscordClient.cs b/src/Discord.Net/WebSocket/DiscordClient.cs deleted file mode 100644 index 911420731..000000000 --- a/src/Discord.Net/WebSocket/DiscordClient.cs +++ /dev/null @@ -1,889 +0,0 @@ -using Discord.API; -using Discord.API.Gateway; -using Discord.API.Rest; -using Discord.Logging; -using Discord.Net; -using Discord.Net.Converters; -using Discord.Net.Queue; -using Discord.Net.WebSockets; -using Discord.WebSocket.Data; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace Discord.WebSocket -{ - //TODO: Docstrings - //TODO: Log Logins/Logouts - //TODO: Do a final namespace and file structure review - public sealed class DiscordClient : IDiscordClient, IDisposable - { - public event Func Log; - public event Func LoggedIn, LoggedOut; - public event Func Connected, Disconnected; - //public event Func VoiceConnected, VoiceDisconnected; - public event Func ChannelCreated, ChannelDestroyed; - public event Func ChannelUpdated; - public event Func MessageReceived, MessageDeleted; - public event Func MessageUpdated; - public event Func RoleCreated, RoleDeleted; - public event Func RoleUpdated; - public event Func JoinedGuild, LeftGuild, GuildAvailable, GuildUnavailable; - public event Func GuildUpdated; - public event Func UserJoined, UserLeft, UserBanned, UserUnbanned; - public event Func UserUpdated; - public event Func UserIsTyping; - - private readonly ConcurrentQueue _largeGuilds; - private readonly Logger _discordLogger, _restLogger, _gatewayLogger; - private readonly SemaphoreSlim _connectionLock; - private readonly DataStoreProvider _dataStoreProvider; - private readonly LogManager _log; - private readonly RequestQueue _requestQueue; - private readonly JsonSerializer _serializer; - private readonly int _connectionTimeout, _reconnectDelay, _failedReconnectDelay; - private readonly bool _enablePreUpdateEvents; - private readonly int _largeThreshold; - private readonly int _totalShards; - private ImmutableDictionary _voiceRegions; - private string _sessionId; - private bool _isDisposed; - - public int ShardId { get; } - public LoginState LoginState { get; private set; } - public ConnectionState ConnectionState { get; private set; } - public API.DiscordApiClient ApiClient { get; private set; } - public IWebSocketClient GatewaySocket { get; private set; } - public IDataStore DataStore { get; private set; } - public SelfUser CurrentUser { get; private set; } - internal int MessageCacheSize { get; private set; } - internal bool UsePermissionCache { get; private set; } - - public IRequestQueue RequestQueue => _requestQueue; - public IEnumerable Guilds => DataStore.Guilds; - public IEnumerable DMChannels => DataStore.Users.Select(x => x.DMChannel).Where(x => x != null); - public IEnumerable VoiceRegions => _voiceRegions.Select(x => x.Value); - - public DiscordClient(DiscordSocketConfig config = null) - { - if (config == null) - config = new DiscordSocketConfig(); - - ShardId = config.ShardId; - _totalShards = config.TotalShards; - - _connectionTimeout = config.ConnectionTimeout; - _reconnectDelay = config.ReconnectDelay; - _failedReconnectDelay = config.FailedReconnectDelay; - _dataStoreProvider = config.DataStoreProvider; - - MessageCacheSize = config.MessageCacheSize; - UsePermissionCache = config.UsePermissionsCache; - _enablePreUpdateEvents = config.EnablePreUpdateEvents; - _largeThreshold = config.LargeThreshold; - - _log = new LogManager(config.LogLevel); - _log.Message += async msg => await Log.Raise(msg).ConfigureAwait(false); - _discordLogger = _log.CreateLogger("Discord"); - _restLogger = _log.CreateLogger("Rest"); - _gatewayLogger = _log.CreateLogger("Gateway"); - - _connectionLock = new SemaphoreSlim(1, 1); - _requestQueue = new RequestQueue(); - _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; - - ApiClient = new API.DiscordApiClient(config.RestClientProvider, config.WebSocketProvider, _serializer, _requestQueue); - ApiClient.SentRequest += async (method, endpoint, millis) => await _restLogger.Verbose($"{method} {endpoint}: {millis} ms"); - ApiClient.SentGatewayMessage += async opCode => await _gatewayLogger.Verbose($"Sent Op {opCode}"); - ApiClient.ReceivedGatewayEvent += ProcessMessage; - GatewaySocket = config.WebSocketProvider(); - - _voiceRegions = ImmutableDictionary.Create(); - _largeGuilds = new ConcurrentQueue(); - } - - void Dispose(bool disposing) - { - if (!_isDisposed) - { - if (disposing) - ApiClient?.Dispose(); - _isDisposed = true; - } - } - public void Dispose() => Dispose(true); - - public async Task Login(string email, string password) - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await LoginInternal(TokenType.User, null, email, password, true, false).ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - public async Task Login(TokenType tokenType, string token, bool validateToken = true) - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await LoginInternal(tokenType, token, null, null, false, validateToken).ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - private async Task LoginInternal(TokenType tokenType, string token, string email, string password, bool useEmail, bool validateToken) - { - if (LoginState != LoginState.LoggedOut) - await LogoutInternal().ConfigureAwait(false); - LoginState = LoginState.LoggingIn; - - try - { - if (useEmail) - { - var args = new LoginParams { Email = email, Password = password }; - await ApiClient.Login(args).ConfigureAwait(false); - } - else - await ApiClient.Login(tokenType, token).ConfigureAwait(false); - - if (validateToken) - { - try - { - await ApiClient.ValidateToken().ConfigureAwait(false); - } - catch (HttpException ex) - { - throw new ArgumentException("Token validation failed", nameof(token), ex); - } - } - - var voiceRegions = await ApiClient.GetVoiceRegions().ConfigureAwait(false); - _voiceRegions = voiceRegions.Select(x => new VoiceRegion(x)).ToImmutableDictionary(x => x.Id); - - LoginState = LoginState.LoggedIn; - } - catch (Exception) - { - await LogoutInternal().ConfigureAwait(false); - throw; - } - - await LoggedIn.Raise().ConfigureAwait(false); - } - - public async Task Logout() - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await LogoutInternal().ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - private async Task LogoutInternal() - { - if (LoginState == LoginState.LoggedOut) return; - LoginState = LoginState.LoggingOut; - - if (ConnectionState != ConnectionState.Disconnected) - await DisconnectInternal().ConfigureAwait(false); - - await ApiClient.Logout().ConfigureAwait(false); - - _voiceRegions = ImmutableDictionary.Create(); - CurrentUser = null; - - LoginState = LoginState.LoggedOut; - - await LoggedOut.Raise().ConfigureAwait(false); - } - - public async Task Connect() - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await ConnectInternal().ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - private async Task ConnectInternal() - { - if (LoginState != LoginState.LoggedIn) - throw new InvalidOperationException("You must log in before connecting."); - - ConnectionState = ConnectionState.Connecting; - try - { - await ApiClient.Connect().ConfigureAwait(false); - - ConnectionState = ConnectionState.Connected; - } - catch (Exception) - { - await DisconnectInternal().ConfigureAwait(false); - throw; - } - - await Connected.Raise().ConfigureAwait(false); - } - - public async Task Disconnect() - { - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - await DisconnectInternal().ConfigureAwait(false); - } - finally { _connectionLock.Release(); } - } - private async Task DisconnectInternal() - { - ulong guildId; - - if (ConnectionState == ConnectionState.Disconnected) return; - ConnectionState = ConnectionState.Disconnecting; - - await ApiClient.Disconnect().ConfigureAwait(false); - while (_largeGuilds.TryDequeue(out guildId)) { } - - ConnectionState = ConnectionState.Disconnected; - - await Disconnected.Raise().ConfigureAwait(false); - } - - public async Task> GetConnections() - { - var models = await ApiClient.GetCurrentUserConnections().ConfigureAwait(false); - return models.Select(x => new Connection(x)); - } - - public Channel GetChannel(ulong id) - { - return DataStore.GetChannel(id); - } - - public async Task GetInvite(string inviteIdOrXkcd) - { - var model = await ApiClient.GetInvite(inviteIdOrXkcd).ConfigureAwait(false); - if (model != null) - return new Invite(this, model); - return null; - } - - public Guild GetGuild(ulong id) - { - return DataStore.GetGuild(id); - } - public async Task CreateGuild(string name, IVoiceRegion region, Stream jpegIcon = null) - { - var args = new CreateGuildParams(); - var model = await ApiClient.CreateGuild(args).ConfigureAwait(false); - return new Guild(this, model); - } - - public User GetUser(ulong id) - { - return DataStore.GetUser(id); - } - public User GetUser(string username, ushort discriminator) - { - return DataStore.Users.Where(x => x.Discriminator == discriminator && x.Username == username).FirstOrDefault(); - } - public async Task> QueryUsers(string query, int limit) - { - var models = await ApiClient.QueryUsers(query, limit).ConfigureAwait(false); - return models.Select(x => new User(this, x)); - } - - public VoiceRegion GetVoiceRegion(string id) - { - VoiceRegion region; - if (_voiceRegions.TryGetValue(id, out region)) - return region; - return null; - } - - private async Task ProcessMessage(GatewayOpCodes opCode, string type, JToken payload) - { - try - { - switch (opCode) - { - case GatewayOpCodes.Dispatch: - switch (type) - { - //Global - case "READY": - { - //TODO: Store guilds even if they're unavailable - //TODO: Make downloading large guilds optional - //TODO: Add support for unavailable guilds - - var data = payload.ToObject(_serializer); - var store = _dataStoreProvider(ShardId, _totalShards, data.Guilds.Length, data.PrivateChannels.Length); - - _sessionId = data.SessionId; - var currentUser = new SelfUser(this, data.User); - store.AddUser(currentUser); - - for (int i = 0; i < data.Guilds.Length; i++) - { - var model = data.Guilds[i]; - var guild = new Guild(this, model); - store.AddGuild(guild); - - foreach (var channel in guild.Channels) - store.AddChannel(channel); - - /*if (model.IsLarge) - _largeGuilds.Enqueue(model.Id);*/ - } - - for (int i = 0; i < data.PrivateChannels.Length; i++) - { - var model = data.PrivateChannels[i]; - var recipient = new User(this, model.Recipient); - var channel = new DMChannel(this, recipient, model); - - recipient.DMChannel = channel; - store.AddChannel(channel); - } - - CurrentUser = currentUser; - DataStore = store; - } - break; - - //Servers - case "GUILD_CREATE": - { - /*var data = msg.Payload.ToObject(Serializer); - if (data.Unavailable != true) - { - var server = AddServer(data.Id); - server.Update(data); - - if (data.Unavailable != false) - { - _gatewayLogger.Info($"GUILD_CREATE: {server.Path}"); - JoinedServer.Raise(server); - } - else - _gatewayLogger.Info($"GUILD_AVAILABLE: {server.Path}"); - - if (!data.IsLarge) - await GuildAvailable.Raise(server); - else - _largeServers.Enqueue(data.Id); - }*/ - } - break; - case "GUILD_UPDATE": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.Id); - if (server != null) - { - var before = Config.EnablePreUpdateEvents ? server.Clone() : null; - server.Update(data); - _gatewayLogger.Info($"GUILD_UPDATE: {server.Path}"); - await GuildUpdated.Raise(before, server); - } - else - _gatewayLogger.Warning("GUILD_UPDATE referenced an unknown guild.");*/ - } - break; - case "GUILD_DELETE": - { - /*var data = msg.Payload.ToObject(Serializer); - Server server = RemoveServer(data.Id); - if (server != null) - { - if (data.Unavailable != true) - _gatewayLogger.Info($"GUILD_DELETE: {server.Path}"); - else - _gatewayLogger.Info($"GUILD_UNAVAILABLE: {server.Path}"); - - OnServerUnavailable(server); - if (data.Unavailable != true) - OnLeftServer(server); - } - else - _gatewayLogger.Warning("GUILD_DELETE referenced an unknown guild.");*/ - } - break; - - //Channels - case "CHANNEL_CREATE": - { - /*var data = msg.Payload.ToObject(Serializer); - - Channel channel = null; - if (data.GuildId != null) - { - var server = GetServer(data.GuildId.Value); - if (server != null) - channel = server.AddChannel(data.Id, true); - else - _gatewayLogger.Warning("CHANNEL_CREATE referenced an unknown guild."); - } - else - channel = AddPrivateChannel(data.Id, data.Recipient.Id); - if (channel != null) - { - channel.Update(data); - _gatewayLogger.Info($"CHANNEL_CREATE: {channel.Path}"); - ChannelCreated.Raise(channel); - }*/ - } - break; - case "CHANNEL_UPDATE": - { - /*var data = msg.Payload.ToObject(Serializer); - var channel = GetChannel(data.Id); - if (channel != null) - { - var before = Config.EnablePreUpdateEvents ? channel.Clone() : null; - channel.Update(data); - _gateway_gatewayLogger.Info($"CHANNEL_UPDATE: {channel.Path}"); - OnChannelUpdated(before, channel); - } - else - _gateway_gatewayLogger.Warning("CHANNEL_UPDATE referenced an unknown channel.");*/ - } - break; - case "CHANNEL_DELETE": - { - /*var data = msg.Payload.ToObject(Serializer); - var channel = RemoveChannel(data.Id); - if (channel != null) - { - _gateway_gatewayLogger.Info($"CHANNEL_DELETE: {channel.Path}"); - OnChannelDestroyed(channel); - } - else - _gateway_gatewayLogger.Warning("CHANNEL_DELETE referenced an unknown channel.");*/ - } - break; - - //Members - case "GUILD_MEMBER_ADD": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId.Value); - if (server != null) - { - var user = server.AddUser(data.User.Id, true, true); - user.Update(data); - user.UpdateActivity(); - _gatewayLogger.Info($"GUILD_MEMBER_ADD: {user.Path}"); - OnUserJoined(user); - } - else - _gatewayLogger.Warning("GUILD_MEMBER_ADD referenced an unknown guild.");*/ - } - break; - case "GUILD_MEMBER_UPDATE": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId.Value); - if (server != null) - { - var user = server.GetUser(data.User.Id); - if (user != null) - { - var before = Config.EnablePreUpdateEvents ? user.Clone() : null; - user.Update(data); - _gatewayLogger.Info($"GUILD_MEMBER_UPDATE: {user.Path}"); - OnUserUpdated(before, user); - } - else - _gatewayLogger.Warning("GUILD_MEMBER_UPDATE referenced an unknown user."); - } - else - _gatewayLogger.Warning("GUILD_MEMBER_UPDATE referenced an unknown guild.");*/ - } - break; - case "GUILD_MEMBER_REMOVE": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId.Value); - if (server != null) - { - var user = server.RemoveUser(data.User.Id); - if (user != null) - { - _gatewayLogger.Info($"GUILD_MEMBER_REMOVE: {user.Path}"); - OnUserLeft(user); - } - else - _gatewayLogger.Warning("GUILD_MEMBER_REMOVE referenced an unknown user."); - } - else - _gatewayLogger.Warning("GUILD_MEMBER_REMOVE referenced an unknown guild.");*/ - } - break; - case "GUILD_MEMBERS_CHUNK": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId); - if (server != null) - { - foreach (var memberData in data.Members) - { - var user = server.AddUser(memberData.User.Id, true, false); - user.Update(memberData); - } - _gateway_gatewayLogger.Verbose($"GUILD_MEMBERS_CHUNK: {data.Members.Length} users"); - - if (server.CurrentUserCount >= server.UserCount) //Finished downloading for there - OnServerAvailable(server); - } - else - _gateway_gatewayLogger.Warning("GUILD_MEMBERS_CHUNK referenced an unknown guild.");*/ - } - break; - - //Roles - case "GUILD_ROLE_CREATE": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId); - if (server != null) - { - var role = server.AddRole(data.Data.Id); - role.Update(data.Data, false); - _gateway_gatewayLogger.Info($"GUILD_ROLE_CREATE: {role.Path}"); - OnRoleCreated(role); - } - else - _gateway_gatewayLogger.Warning("GUILD_ROLE_CREATE referenced an unknown guild.");*/ - } - break; - case "GUILD_ROLE_UPDATE": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId); - if (server != null) - { - var role = server.GetRole(data.Data.Id); - if (role != null) - { - var before = Config.EnablePreUpdateEvents ? role.Clone() : null; - role.Update(data.Data, true); - _gateway_gatewayLogger.Info($"GUILD_ROLE_UPDATE: {role.Path}"); - OnRoleUpdated(before, role); - } - else - _gateway_gatewayLogger.Warning("GUILD_ROLE_UPDATE referenced an unknown role."); - } - else - _gateway_gatewayLogger.Warning("GUILD_ROLE_UPDATE referenced an unknown guild.");*/ - } - break; - case "GUILD_ROLE_DELETE": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId); - if (server != null) - { - var role = server.RemoveRole(data.RoleId); - if (role != null) - { - _gateway_gatewayLogger.Info($"GUILD_ROLE_DELETE: {role.Path}"); - OnRoleDeleted(role); - } - else - _gateway_gatewayLogger.Warning("GUILD_ROLE_DELETE referenced an unknown role."); - } - else - _gateway_gatewayLogger.Warning("GUILD_ROLE_DELETE referenced an unknown guild.");*/ - } - break; - - //Bans - case "GUILD_BAN_ADD": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId.Value); - if (server != null) - { - var user = server.GetUser(data.User.Id); - if (user != null) - { - _gateway_gatewayLogger.Info($"GUILD_BAN_ADD: {user.Path}"); - OnUserBanned(user); - } - else - _gateway_gatewayLogger.Warning("GUILD_BAN_ADD referenced an unknown user."); - } - else - _gateway_gatewayLogger.Warning("GUILD_BAN_ADD referenced an unknown guild.");*/ - } - break; - case "GUILD_BAN_REMOVE": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId.Value); - if (server != null) - { - var user = new User(this, data.User.Id, server); - user.Update(data.User); - _gateway_gatewayLogger.Info($"GUILD_BAN_REMOVE: {user.Path}"); - OnUserUnbanned(user); - } - else - _gateway_gatewayLogger.Warning("GUILD_BAN_REMOVE referenced an unknown guild.");*/ - } - break; - - //Messages - case "MESSAGE_CREATE": - { - /*var data = msg.Payload.ToObject(Serializer); - - Channel channel = GetChannel(data.ChannelId); - if (channel != null) - { - var user = channel.GetUserFast(data.Author.Id); - - if (user != null) - { - Message msg = null; - bool isAuthor = data.Author.Id == CurrentUser.Id; - //ulong nonce = 0; - - //if (data.Author.Id == _privateUser.Id && Config.UseMessageQueue) - //{ - // if (data.Nonce != null && ulong.TryParse(data.Nonce, out nonce)) - // msg = _messages[nonce]; - //} - if (msg == null) - { - msg = channel.AddMessage(data.Id, user, data.Timestamp.Value); - //nonce = 0; - } - - //Remapped queued message - //if (nonce != 0) - //{ - // msg = _messages.Remap(nonce, data.Id); - // msg.Id = data.Id; - // RaiseMessageSent(msg); - //} - - msg.Update(data); - user.UpdateActivity(); - - _gateway_gatewayLogger.Verbose($"MESSAGE_CREATE: {channel.Path} ({user.Name ?? "Unknown"})"); - OnMessageReceived(msg); - } - else - _gateway_gatewayLogger.Warning("MESSAGE_CREATE referenced an unknown user."); - } - else - _gateway_gatewayLogger.Warning("MESSAGE_CREATE referenced an unknown channel.");*/ - } - break; - case "MESSAGE_UPDATE": - { - /*var data = msg.Payload.ToObject(Serializer); - var channel = GetChannel(data.ChannelId); - if (channel != null) - { - var msg = channel.GetMessage(data.Id, data.Author?.Id); - var before = Config.EnablePreUpdateEvents ? msg.Clone() : null; - msg.Update(data); - _gatewayLogger.Verbose($"MESSAGE_UPDATE: {channel.Path} ({data.Author?.Username ?? "Unknown"})"); - OnMessageUpdated(before, msg); - } - else - _gatewayLogger.Warning("MESSAGE_UPDATE referenced an unknown channel.");*/ - } - break; - case "MESSAGE_DELETE": - { - /*var data = msg.Payload.ToObject(Serializer); - var channel = GetChannel(data.ChannelId); - if (channel != null) - { - var msg = channel.RemoveMessage(data.Id); - _gatewayLogger.Verbose($"MESSAGE_DELETE: {channel.Path} ({msg.User?.Name ?? "Unknown"})"); - OnMessageDeleted(msg); - } - else - _gatewayLogger.Warning("MESSAGE_DELETE referenced an unknown channel.");*/ - } - break; - - //Statuses - case "PRESENCE_UPDATE": - { - /*var data = msg.Payload.ToObject(Serializer); - User user; - Server server; - if (data.GuildId == null) - { - server = null; - user = GetPrivateChannel(data.User.Id)?.Recipient; - } - else - { - server = GetServer(data.GuildId.Value); - if (server == null) - { - _gatewayLogger.Warning("PRESENCE_UPDATE referenced an unknown server."); - break; - } - else - user = server.GetUser(data.User.Id); - } - - if (user != null) - { - if (Config.LogLevel == LogSeverity.Debug) - _gatewayLogger.Debug($"PRESENCE_UPDATE: {user.Path}"); - var before = Config.EnablePreUpdateEvents ? user.Clone() : null; - user.Update(data); - OnUserUpdated(before, user); - } - //else //Occurs when a user leaves a server - // _gatewayLogger.Warning("PRESENCE_UPDATE referenced an unknown user.");*/ - } - break; - case "TYPING_START": - { - /*var data = msg.Payload.ToObject(Serializer); - var channel = GetChannel(data.ChannelId); - if (channel != null) - { - User user; - if (channel.IsPrivate) - { - if (channel.Recipient.Id == data.UserId) - user = channel.Recipient; - else - break; - } - else - user = channel.Server.GetUser(data.UserId); - if (user != null) - { - if (Config.LogLevel == LogSeverity.Debug) - _gatewayLogger.Debug($"TYPING_START: {channel.Path} ({user.Name})"); - OnUserIsTypingUpdated(channel, user); - user.UpdateActivity(); - } - } - else - _gatewayLogger.Warning("TYPING_START referenced an unknown channel.");*/ - } - break; - - //Voice - case "VOICE_STATE_UPDATE": - { - /*var data = msg.Payload.ToObject(Serializer); - var server = GetServer(data.GuildId); - if (server != null) - { - var user = server.GetUser(data.UserId); - if (user != null) - { - if (Config.LogLevel == LogSeverity.Debug) - _gatewayLogger.Debug($"VOICE_STATE_UPDATE: {user.Path}"); - var before = Config.EnablePreUpdateEvents ? user.Clone() : null; - user.Update(data); - //_gatewayLogger.Verbose($"Voice Updated: {server.Name}/{user.Name}"); - OnUserUpdated(before, user); - } - //else //Occurs when a user leaves a server - // _gatewayLogger.Warning("VOICE_STATE_UPDATE referenced an unknown user."); - } - else - _gatewayLogger.Warning("VOICE_STATE_UPDATE referenced an unknown server.");*/ - } - break; - - //Settings - case "USER_UPDATE": - { - /*var data = msg.Payload.ToObject(Serializer); - if (data.Id == CurrentUser.Id) - { - var before = Config.EnablePreUpdateEvents ? CurrentUser.Clone() : null; - CurrentUser.Update(data); - foreach (var server in _servers) - server.Value.CurrentUser.Update(data); - _gatewayLogger.Info($"USER_UPDATE"); - OnProfileUpdated(before, CurrentUser); - }*/ - } - break; - - //Handled in GatewaySocket - case "RESUMED": - break; - - //Ignored - case "USER_SETTINGS_UPDATE": - case "MESSAGE_ACK": //TODO: Add (User only) - case "GUILD_EMOJIS_UPDATE": //TODO: Add - case "GUILD_INTEGRATIONS_UPDATE": //TODO: Add - case "VOICE_SERVER_UPDATE": //TODO: Add - _gatewayLogger.Debug($"Ignored message {opCode}{(type != null ? $" ({type})" : "")}"); - break; - - //Others - default: - _gatewayLogger.Warning($"Unknown message {opCode}{(type != null ? $" ({type})" : "")}"); - break; - } - break; - } - } - catch (Exception ex) - { - _gatewayLogger.Error($"Error handling msg {opCode}{(type != null ? $" ({type})" : "")}", ex); - } - } - - Task IDiscordClient.GetChannel(ulong id) - => Task.FromResult(GetChannel(id)); - Task> IDiscordClient.GetDMChannels() - => Task.FromResult>(DMChannels.ToImmutableArray()); - async Task> IDiscordClient.GetConnections() - => await GetConnections().ConfigureAwait(false); - async Task IDiscordClient.GetInvite(string inviteIdOrXkcd) - => await GetInvite(inviteIdOrXkcd).ConfigureAwait(false); - Task IDiscordClient.GetGuild(ulong id) - => Task.FromResult(GetGuild(id)); - Task> IDiscordClient.GetGuilds() - => Task.FromResult>(Guilds.ToImmutableArray()); - async Task IDiscordClient.CreateGuild(string name, IVoiceRegion region, Stream jpegIcon) - => await CreateGuild(name, region, jpegIcon).ConfigureAwait(false); - Task IDiscordClient.GetUser(ulong id) - => Task.FromResult(GetUser(id)); - Task IDiscordClient.GetUser(string username, ushort discriminator) - => Task.FromResult(GetUser(username, discriminator)); - Task IDiscordClient.GetCurrentUser() - => Task.FromResult(CurrentUser); - async Task> IDiscordClient.QueryUsers(string query, int limit) - => await QueryUsers(query, limit).ConfigureAwait(false); - Task> IDiscordClient.GetVoiceRegions() - => Task.FromResult>(VoiceRegions.ToImmutableArray()); - Task IDiscordClient.GetVoiceRegion(string id) - => Task.FromResult(GetVoiceRegion(id)); - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/Channel.cs b/src/Discord.Net/WebSocket/Entities/Channels/Channel.cs deleted file mode 100644 index b645d1c20..000000000 --- a/src/Discord.Net/WebSocket/Entities/Channels/Channel.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Discord.WebSocket -{ - //TODO: Look into Internal abstract pattern - can we get rid of this? - public abstract class Channel : IChannel - { - /// - public ulong Id { get; private set; } - public IEnumerable Users => GetUsersInternal(); - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - - internal Channel(ulong id) - { - Id = id; - } - - /// - public User GetUser(ulong id) - => GetUserInternal(id); - - protected abstract User GetUserInternal(ulong id); - protected abstract IEnumerable GetUsersInternal(); - - Task> IChannel.GetUsers() - => Task.FromResult>(GetUsersInternal()); - Task> IChannel.GetUsers(int limit, int offset) - => Task.FromResult>(GetUsersInternal().Skip(offset).Take(limit)); - Task IChannel.GetUser(ulong id) - => Task.FromResult(GetUserInternal(id)); - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/DMChannel.cs b/src/Discord.Net/WebSocket/Entities/Channels/DMChannel.cs deleted file mode 100644 index bb93566af..000000000 --- a/src/Discord.Net/WebSocket/Entities/Channels/DMChannel.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Discord.API.Rest; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Channel; - -namespace Discord.WebSocket -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class DMChannel : Channel, IDMChannel - { - private readonly MessageCache _messages; - - internal DiscordClient Discord { get; } - - /// - public User Recipient { get; private set; } - - /// - public new IEnumerable Users => ImmutableArray.Create(Discord.CurrentUser, Recipient); - public IEnumerable CachedMessages => _messages.Messages; - - internal DMChannel(DiscordClient discord, User recipient, Model model) - : base(model.Id) - { - Discord = discord; - Recipient = recipient; - _messages = new MessageCache(Discord, this); - - Update(model); - } - private void Update(Model model) - { - Recipient.Update(model.Recipient); - } - - protected override User GetUserInternal(ulong id) - { - if (id == Recipient.Id) - return Recipient; - else if (id == Discord.CurrentUser.Id) - return Discord.CurrentUser; - else - return null; - } - protected override IEnumerable GetUsersInternal() - { - return Users; - } - - /// Gets the message from this channel's cache with the given id, or null if none was found. - public Message GetCachedMessage(ulong id) - { - return _messages.Get(id); - } - /// Gets the last N messages from this message channel. - public async Task> GetMessages(int limit = DiscordConfig.MaxMessagesPerBatch) - { - return await _messages.Download(null, Direction.Before, limit).ConfigureAwait(false); - } - /// Gets a collection of messages in this channel. - public async Task> GetMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - { - return await _messages.Download(fromMessageId, dir, limit).ConfigureAwait(false); - } - - /// - public async Task SendMessage(string text, bool isTTS = false) - { - var args = new CreateMessageParams { Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.CreateDMMessage(Id, args).ConfigureAwait(false); - return new Message(this, GetUser(model.Id), model); - } - /// - public async Task SendFile(string filePath, string text = null, bool isTTS = false) - { - string filename = Path.GetFileName(filePath); - using (var file = File.OpenRead(filePath)) - { - var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.UploadDMFile(Id, file, args).ConfigureAwait(false); - return new Message(this, GetUser(model.Id), model); - } - } - /// - public async Task SendFile(Stream stream, string filename, string text = null, bool isTTS = false) - { - var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.UploadDMFile(Id, stream, args).ConfigureAwait(false); - return new Message(this, GetUser(model.Id), model); - } - - /// - public async Task DeleteMessages(IEnumerable messages) - { - await Discord.ApiClient.DeleteDMMessages(Id, new DeleteMessagesParam { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); - } - - /// - public async Task TriggerTyping() - { - await Discord.ApiClient.TriggerTypingIndicator(Id).ConfigureAwait(false); - } - - /// - public async Task Close() - { - await Discord.ApiClient.DeleteChannel(Id).ConfigureAwait(false); - } - - /// - public override string ToString() => '@' + Recipient.ToString(); - private string DebuggerDisplay => $"@{Recipient} ({Id}, DM)"; - - IUser IDMChannel.Recipient => Recipient; - IEnumerable IMessageChannel.CachedMessages => CachedMessages; - - Task> IChannel.GetUsers() - => Task.FromResult>(Users); - Task> IChannel.GetUsers(int limit, int offset) - => Task.FromResult>(Users.Skip(offset).Take(limit)); - Task IChannel.GetUser(ulong id) - => Task.FromResult(GetUser(id)); - Task IMessageChannel.GetCachedMessage(ulong id) - => Task.FromResult(GetCachedMessage(id)); - async Task> IMessageChannel.GetMessages(int limit) - => await GetMessages(limit).ConfigureAwait(false); - async Task> IMessageChannel.GetMessages(ulong fromMessageId, Direction dir, int limit) - => await GetMessages(fromMessageId, dir, limit).ConfigureAwait(false); - async Task IMessageChannel.SendMessage(string text, bool isTTS) - => await SendMessage(text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.SendFile(string filePath, string text, bool isTTS) - => await SendFile(filePath, text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.SendFile(Stream stream, string filename, string text, bool isTTS) - => await SendFile(stream, filename, text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.TriggerTyping() - => await TriggerTyping().ConfigureAwait(false); - Task IUpdateable.Update() - => Task.CompletedTask; - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/GuildChannel.cs b/src/Discord.Net/WebSocket/Entities/Channels/GuildChannel.cs deleted file mode 100644 index db571577a..000000000 --- a/src/Discord.Net/WebSocket/Entities/Channels/GuildChannel.cs +++ /dev/null @@ -1,161 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Channel; - -namespace Discord.WebSocket -{ - public abstract class GuildChannel : Channel, IGuildChannel - { - private ConcurrentDictionary _overwrites; - internal PermissionsCache _permissions; - - /// Gets the guild this channel is a member of. - public Guild Guild { get; } - - /// - public string Name { get; private set; } - /// - public int Position { get; private set; } - public new abstract IEnumerable Users { get; } - - /// - public IReadOnlyDictionary PermissionOverwrites => _overwrites; - internal DiscordClient Discord => Guild.Discord; - - internal GuildChannel(Guild guild, Model model) - : base(model.Id) - { - Guild = guild; - - Update(model); - } - internal virtual void Update(Model model) - { - Name = model.Name; - Position = model.Position; - - var newOverwrites = new ConcurrentDictionary(); - for (int i = 0; i < model.PermissionOverwrites.Length; i++) - { - var overwrite = model.PermissionOverwrites[i]; - newOverwrites[overwrite.TargetId] = new Overwrite(overwrite); - } - _overwrites = newOverwrites; - } - - public async Task Modify(Action func) - { - if (func != null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildChannelParams(); - func(args); - await Discord.ApiClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); - } - - /// Gets a user in this channel with the given id. - public new abstract GuildUser GetUser(ulong id); - protected override User GetUserInternal(ulong id) - { - return GetUser(id).GlobalUser; - } - protected override IEnumerable GetUsersInternal() - { - return Users.Select(x => x.GlobalUser); - } - - /// - public OverwritePermissions? GetPermissionOverwrite(IUser user) - { - Overwrite value; - if (_overwrites.TryGetValue(Id, out value)) - return value.Permissions; - return null; - } - /// - public OverwritePermissions? GetPermissionOverwrite(IRole role) - { - Overwrite value; - if (_overwrites.TryGetValue(Id, out value)) - return value.Permissions; - return null; - } - /// Downloads a collection of all invites to this channel. - public async Task> GetInvites() - { - var models = await Discord.ApiClient.GetChannelInvites(Id).ConfigureAwait(false); - return models.Select(x => new InviteMetadata(Discord, x)); - } - - /// - public async Task AddPermissionOverwrite(IUser user, OverwritePermissions perms) - { - var args = new ModifyChannelPermissionsParams { Allow = perms.AllowValue, Deny = perms.DenyValue }; - await Discord.ApiClient.ModifyChannelPermissions(Id, user.Id, args).ConfigureAwait(false); - } - /// - public async Task AddPermissionOverwrite(IRole role, OverwritePermissions perms) - { - var args = new ModifyChannelPermissionsParams { Allow = perms.AllowValue, Deny = perms.DenyValue }; - await Discord.ApiClient.ModifyChannelPermissions(Id, role.Id, args).ConfigureAwait(false); - } - /// - public async Task RemovePermissionOverwrite(IUser user) - { - await Discord.ApiClient.DeleteChannelPermission(Id, user.Id).ConfigureAwait(false); - } - /// - public async Task RemovePermissionOverwrite(IRole role) - { - await Discord.ApiClient.DeleteChannelPermission(Id, role.Id).ConfigureAwait(false); - } - - /// Creates a new invite to this channel. - /// Time (in seconds) until the invite expires. Set to null to never expire. - /// The max amount of times this invite may be used. Set to null to have unlimited uses. - /// If true, a user accepting this invite will be kicked from the guild after closing their client. - /// If true, creates a human-readable link. Not supported if maxAge is set to null. - public async Task CreateInvite(int? maxAge = 1800, int? maxUses = null, bool isTemporary = false, bool withXkcd = false) - { - var args = new CreateChannelInviteParams - { - MaxAge = maxAge ?? 0, - MaxUses = maxUses ?? 0, - Temporary = isTemporary, - XkcdPass = withXkcd - }; - var model = await Discord.ApiClient.CreateChannelInvite(Id, args).ConfigureAwait(false); - return new InviteMetadata(Discord, model); - } - - /// - public async Task Delete() - { - await Discord.ApiClient.DeleteChannel(Id).ConfigureAwait(false); - } - - /// - public override string ToString() => Name; - - IGuild IGuildChannel.Guild => Guild; - async Task IGuildChannel.CreateInvite(int? maxAge, int? maxUses, bool isTemporary, bool withXkcd) - => await CreateInvite(maxAge, maxUses, isTemporary, withXkcd).ConfigureAwait(false); - async Task> IGuildChannel.GetInvites() - => await GetInvites().ConfigureAwait(false); - Task> IGuildChannel.GetUsers() - => Task.FromResult>(Users); - Task IGuildChannel.GetUser(ulong id) - => Task.FromResult(GetUser(id)); - Task> IChannel.GetUsers() - => Task.FromResult>(Users); - Task> IChannel.GetUsers(int limit, int offset) - => Task.FromResult>(Users.Skip(offset).Take(limit)); - Task IChannel.GetUser(ulong id) - => Task.FromResult(GetUser(id)); - Task IUpdateable.Update() - => Task.CompletedTask; - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/TextChannel.cs b/src/Discord.Net/WebSocket/Entities/Channels/TextChannel.cs deleted file mode 100644 index ade45276e..000000000 --- a/src/Discord.Net/WebSocket/Entities/Channels/TextChannel.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Channel; - -namespace Discord.WebSocket -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class TextChannel : GuildChannel, ITextChannel - { - private readonly MessageCache _messages; - - /// - public string Topic { get; private set; } - - /// - public string Mention => MentionUtils.Mention(this); - public override IEnumerable Users - => _permissions.Members.Where(x => x.Permissions.ReadMessages).Select(x => x.User).ToImmutableArray(); - public IEnumerable CachedMessages => _messages.Messages; - - internal TextChannel(Guild guild, Model model) - : base(guild, model) - { - _messages = new MessageCache(Discord, this); - } - - internal override void Update(Model model) - { - Topic = model.Topic; - base.Update(model); - } - - public async Task Modify(Action func) - { - if (func != null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyTextChannelParams(); - func(args); - await Discord.ApiClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); - } - - /// Gets the message from this channel's cache with the given id, or null if none was found. - public Message GetCachedMessage(ulong id) - { - return _messages.Get(id); - } - /// Gets the last N messages from this message channel. - public async Task> GetMessages(int limit = DiscordConfig.MaxMessagesPerBatch) - { - return await _messages.Download(null, Direction.Before, limit).ConfigureAwait(false); - } - /// Gets a collection of messages in this channel. - public async Task> GetMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - { - return await _messages.Download(fromMessageId, dir, limit).ConfigureAwait(false); - } - - public override GuildUser GetUser(ulong id) - { - var member = _permissions.Get(id); - if (member != null && member.Value.Permissions.ReadMessages) - return member.Value.User; - return null; - } - - /// - public async Task SendMessage(string text, bool isTTS = false) - { - var args = new CreateMessageParams { Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.CreateMessage(Guild.Id, Id, args).ConfigureAwait(false); - return new Message(this, GetUser(model.Id), model); - } - /// - public async Task SendFile(string filePath, string text = null, bool isTTS = false) - { - string filename = Path.GetFileName(filePath); - using (var file = File.OpenRead(filePath)) - { - var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.UploadFile(Guild.Id, Id, file, args).ConfigureAwait(false); - return new Message(this, GetUser(model.Author.Id), model); - } - } - /// - public async Task SendFile(Stream stream, string filename, string text = null, bool isTTS = false) - { - var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; - var model = await Discord.ApiClient.UploadFile(Guild.Id, Id, stream, args).ConfigureAwait(false); - return new Message(this, GetUser(model.Author.Id), model); - } - - /// - public async Task DeleteMessages(IEnumerable messages) - { - await Discord.ApiClient.DeleteMessages(Guild.Id, Id, new DeleteMessagesParam { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); - } - - /// - public async Task TriggerTyping() - { - await Discord.ApiClient.TriggerTypingIndicator(Id).ConfigureAwait(false); - } - - private string DebuggerDisplay => $"{Name} ({Id}, Text)"; - - IEnumerable IMessageChannel.CachedMessages => CachedMessages; - - Task IMessageChannel.GetCachedMessage(ulong id) - => Task.FromResult(GetCachedMessage(id)); - async Task> IMessageChannel.GetMessages(int limit) - => await GetMessages(limit).ConfigureAwait(false); - async Task> IMessageChannel.GetMessages(ulong fromMessageId, Direction dir, int limit) - => await GetMessages(fromMessageId, dir, limit).ConfigureAwait(false); - async Task IMessageChannel.SendMessage(string text, bool isTTS) - => await SendMessage(text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.SendFile(string filePath, string text, bool isTTS) - => await SendFile(filePath, text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.SendFile(Stream stream, string filename, string text, bool isTTS) - => await SendFile(stream, filename, text, isTTS).ConfigureAwait(false); - async Task IMessageChannel.TriggerTyping() - => await TriggerTyping().ConfigureAwait(false); - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/VoiceChannel.cs b/src/Discord.Net/WebSocket/Entities/Channels/VoiceChannel.cs deleted file mode 100644 index d1f374499..000000000 --- a/src/Discord.Net/WebSocket/Entities/Channels/VoiceChannel.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Channel; - -namespace Discord.WebSocket -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class VoiceChannel : GuildChannel, IVoiceChannel - { - /// - public int Bitrate { get; private set; } - /// - public int UserLimit { get; private set; } - - public override IEnumerable Users - => Guild.Users.Where(x => x.VoiceChannel == this); - - internal VoiceChannel(Guild guild, Model model) - : base(guild, model) - { - } - internal override void Update(Model model) - { - base.Update(model); - Bitrate = model.Bitrate; - UserLimit = model.UserLimit; - } - - /// - public async Task Modify(Action func) - { - if (func != null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyVoiceChannelParams(); - func(args); - await Discord.ApiClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); - } - - public override GuildUser GetUser(ulong id) - { - var member = _permissions.Get(id); - if (member != null && member.Value.Permissions.ReadMessages) - return member.Value.User; - return null; - } - - private string DebuggerDisplay => $"{Name} ({Id}, Voice)"; - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Guilds/Guild.cs b/src/Discord.Net/WebSocket/Entities/Guilds/Guild.cs deleted file mode 100644 index c0663e924..000000000 --- a/src/Discord.Net/WebSocket/Entities/Guilds/Guild.cs +++ /dev/null @@ -1,344 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Guild; -using System.Diagnostics; - -namespace Discord.WebSocket -{ - /// Represents a Discord guild (called a server in the official client). - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class Guild : IGuild, IUserGuild - { - private ConcurrentHashSet _channels; - private ConcurrentDictionary _members; - private ConcurrentDictionary _roles; - private ulong _ownerId; - private ulong? _afkChannelId, _embedChannelId; - private string _iconId, _splashId; - private int _userCount; - - /// - public ulong Id { get; } - internal DiscordClient Discord { get; } - - /// - public string Name { get; private set; } - /// - public int AFKTimeout { get; private set; } - /// - public bool IsEmbeddable { get; private set; } - /// - public int VerificationLevel { get; private set; } - - /// - public VoiceRegion VoiceRegion { get; private set; } - /// - public IReadOnlyList Emojis { get; private set; } - /// - public IReadOnlyList Features { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - - /// - public string IconUrl => API.CDN.GetGuildIconUrl(Id, _iconId); - /// - public string SplashUrl => API.CDN.GetGuildSplashUrl(Id, _splashId); - - /// Gets the number of channels in this guild. - public int ChannelCount => _channels.Count; - /// Gets the number of roles in this guild. - public int RoleCount => _roles.Count; - /// Gets the number of users in this guild. - public int UserCount => _userCount; - /// Gets the number of users downloaded for this guild so far. - internal int CurrentUserCount => _members.Count; - - /// Gets the the role representing all users in a guild. - public Role EveryoneRole => GetRole(Id); - public GuildUser CurrentUser => GetUser(Discord.CurrentUser.Id); - /// Gets the user that created this guild. - public GuildUser Owner => GetUser(_ownerId); - /// Gets the default channel for this guild. - public TextChannel DefaultChannel => GetChannel(Id) as TextChannel; - /// Gets the AFK voice channel for this guild. - public VoiceChannel AFKChannel => GetChannel(_afkChannelId.GetValueOrDefault(0)) as VoiceChannel; - /// Gets the embed channel for this guild. - public IChannel EmbedChannel => GetChannel(_embedChannelId.GetValueOrDefault(0)); //TODO: Is this text or voice? - /// Gets a collection of all channels in this guild. - public IEnumerable Channels => _channels.Select(x => Discord.GetChannel(x) as GuildChannel); - /// Gets a collection of text channels in this guild. - public IEnumerable TextChannels => _channels.Select(x => Discord.GetChannel(x) as TextChannel); - /// Gets a collection of voice channels in this guild. - public IEnumerable VoiceChannels => _channels.Select(x => Discord.GetChannel(x) as VoiceChannel); - /// Gets a collection of all roles in this guild. - public IEnumerable Roles => _roles?.Select(x => x.Value) ?? Enumerable.Empty(); - /// Gets a collection of all users in this guild. - public IEnumerable Users => _members.Select(x => x.Value); - - internal Guild(DiscordClient discord, Model model) - { - Id = model.Id; - Discord = discord; - - Update(model); - } - private async void Update(Model model) - { - _afkChannelId = model.AFKChannelId; - AFKTimeout = model.AFKTimeout; - _embedChannelId = model.EmbedChannelId; - IsEmbeddable = model.EmbedEnabled; - Features = model.Features; - _iconId = model.Icon; - Name = model.Name; - _ownerId = model.OwnerId; - VoiceRegion = Discord.GetVoiceRegion(model.Region); - _splashId = model.Splash; - VerificationLevel = model.VerificationLevel; - - if (model.Emojis != null) - { - var emojis = ImmutableArray.CreateBuilder(model.Emojis.Length); - for (int i = 0; i < model.Emojis.Length; i++) - emojis.Add(new Emoji(model.Emojis[i])); - Emojis = emojis.ToArray(); - } - else - Emojis = Array.Empty(); - - var roles = new ConcurrentDictionary(1, model.Roles?.Length ?? 0); - if (model.Roles != null) - { - for (int i = 0; i < model.Roles.Length; i++) - roles[model.Roles[i].Id] = new Role(this, model.Roles[i]); - } - _roles = roles; - } - - /// - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildParams(); - func(args); - await Discord.ApiClient.ModifyGuild(Id, args).ConfigureAwait(false); - } - /// - public async Task ModifyEmbed(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildEmbedParams(); - func(args); - await Discord.ApiClient.ModifyGuildEmbed(Id, args).ConfigureAwait(false); - } - /// - public async Task ModifyChannels(IEnumerable args) - { - await Discord.ApiClient.ModifyGuildChannels(Id, args).ConfigureAwait(false); - } - /// - public async Task ModifyRoles(IEnumerable args) - { - await Discord.ApiClient.ModifyGuildRoles(Id, args).ConfigureAwait(false); - } - /// - public async Task Leave() - { - await Discord.ApiClient.LeaveGuild(Id).ConfigureAwait(false); - } - /// - public async Task Delete() - { - await Discord.ApiClient.DeleteGuild(Id).ConfigureAwait(false); - } - - /// - public async Task> GetBans() - { - var models = await Discord.ApiClient.GetGuildBans(Id).ConfigureAwait(false); - return models.Select(x => new User(Discord, x)); - } - /// - public Task AddBan(IUser user, int pruneDays = 0) => AddBan(user, pruneDays); - /// - public async Task AddBan(ulong userId, int pruneDays = 0) - { - var args = new CreateGuildBanParams() { PruneDays = pruneDays }; - await Discord.ApiClient.CreateGuildBan(Id, userId, args).ConfigureAwait(false); - } - /// - public Task RemoveBan(IUser user) => RemoveBan(user.Id); - /// - public async Task RemoveBan(ulong userId) - { - await Discord.ApiClient.RemoveGuildBan(Id, userId).ConfigureAwait(false); - } - - /// Gets the channel in this guild with the provided id, or null if not found. - public GuildChannel GetChannel(ulong id) - { - if (_channels.ContainsKey(id)) - return Discord.GetChannel(id) as GuildChannel; - return null; - } - /// Creates a new text channel. - public async Task CreateTextChannel(string name) - { - if (name == null) throw new ArgumentNullException(nameof(name)); - - var args = new CreateGuildChannelParams() { Name = name, Type = ChannelType.Text }; - var model = await Discord.ApiClient.CreateGuildChannel(Id, args).ConfigureAwait(false); - return new TextChannel(this, model); - } - /// Creates a new voice channel. - public async Task CreateVoiceChannel(string name) - { - if (name == null) throw new ArgumentNullException(nameof(name)); - - var args = new CreateGuildChannelParams { Name = name, Type = ChannelType.Voice }; - var model = await Discord.ApiClient.CreateGuildChannel(Id, args).ConfigureAwait(false); - return new VoiceChannel(this, model); - } - - /// Creates a new integration for this guild. - public async Task CreateIntegration(ulong id, string type) - { - var args = new CreateGuildIntegrationParams { Id = id, Type = type }; - var model = await Discord.ApiClient.CreateGuildIntegration(Id, args).ConfigureAwait(false); - return new GuildIntegration(this, model); - } - - /// Gets a collection of all invites to this guild. - public async Task> GetInvites() - { - var models = await Discord.ApiClient.GetGuildInvites(Id).ConfigureAwait(false); - return models.Select(x => new InviteMetadata(Discord, x)); - } - /// Creates a new invite to this guild. - public async Task CreateInvite(int? maxAge = 1800, int? maxUses = null, bool isTemporary = false, bool withXkcd = false) - { - if (maxAge <= 0) throw new ArgumentOutOfRangeException(nameof(maxAge)); - if (maxUses <= 0) throw new ArgumentOutOfRangeException(nameof(maxUses)); - - var args = new CreateChannelInviteParams() - { - MaxAge = maxAge ?? 0, - MaxUses = maxUses ?? 0, - Temporary = isTemporary, - XkcdPass = withXkcd - }; - var model = await Discord.ApiClient.CreateChannelInvite(Id, args).ConfigureAwait(false); - return new InviteMetadata(Discord, model); - } - - /// Gets the role in this guild with the provided id, or null if not found. - public Role GetRole(ulong id) - { - Role result = null; - if (_roles?.TryGetValue(id, out result) == true) - return result; - return null; - } - - /// Creates a new role. - public async Task CreateRole(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false) - { - if (name == null) throw new ArgumentNullException(nameof(name)); - - var model = await Discord.ApiClient.CreateGuildRole(Id).ConfigureAwait(false); - var role = new Role(this, model); - - await role.Modify(x => - { - x.Name = name; - x.Permissions = (permissions ?? role.Permissions).RawValue; - x.Color = (color ?? Color.Default).RawValue; - x.Hoist = isHoisted; - }).ConfigureAwait(false); - - return role; - } - - /// Gets the user in this guild with the provided id, or null if not found. - public GuildUser GetUser(ulong id) - { - GuildUser user; - if (_members.TryGetValue(id, out user)) - return user; - return null; - } - public async Task PruneUsers(int days = 30, bool simulate = false) - { - var args = new GuildPruneParams() { Days = days }; - GetGuildPruneCountResponse model; - if (simulate) - model = await Discord.ApiClient.GetGuildPruneCount(Id, args).ConfigureAwait(false); - else - model = await Discord.ApiClient.BeginGuildPrune(Id, args).ConfigureAwait(false); - return model.Pruned; - } - - internal GuildChannel ToChannel(API.Channel model) - { - switch (model.Type) - { - case ChannelType.Text: - default: - return new TextChannel(this, model); - case ChannelType.Voice: - return new VoiceChannel(this, model); - } - } - - public override string ToString() => Name; - private string DebuggerDisplay => $"{Name} ({Id})"; - - IEnumerable IGuild.Emojis => Emojis; - IEnumerable IGuild.Features => Features; - ulong? IGuild.AFKChannelId => _afkChannelId; - ulong IGuild.DefaultChannelId => Id; - ulong? IGuild.EmbedChannelId => _embedChannelId; - ulong IGuild.EveryoneRoleId => EveryoneRole.Id; - ulong IGuild.OwnerId => _ownerId; - string IGuild.VoiceRegionId => VoiceRegion.Id; - bool IUserGuild.IsOwner => CurrentUser.Id == _ownerId; - GuildPermissions IUserGuild.Permissions => CurrentUser.GuildPermissions; - - async Task> IGuild.GetBans() - => await GetBans().ConfigureAwait(false); - Task IGuild.GetChannel(ulong id) - => Task.FromResult(GetChannel(id)); - Task> IGuild.GetChannels() - => Task.FromResult>(Channels); - async Task IGuild.CreateInvite(int? maxAge, int? maxUses, bool isTemporary, bool withXkcd) - => await CreateInvite(maxAge, maxUses, isTemporary, withXkcd).ConfigureAwait(false); - async Task IGuild.CreateRole(string name, GuildPermissions? permissions, Color? color, bool isHoisted) - => await CreateRole(name, permissions, color, isHoisted).ConfigureAwait(false); - async Task IGuild.CreateTextChannel(string name) - => await CreateTextChannel(name).ConfigureAwait(false); - async Task IGuild.CreateVoiceChannel(string name) - => await CreateVoiceChannel(name).ConfigureAwait(false); - async Task> IGuild.GetInvites() - => await GetInvites().ConfigureAwait(false); - Task IGuild.GetRole(ulong id) - => Task.FromResult(GetRole(id)); - Task> IGuild.GetRoles() - => Task.FromResult>(Roles); - Task IGuild.GetUser(ulong id) - => Task.FromResult(GetUser(id)); - Task IGuild.GetCurrentUser() - => Task.FromResult(CurrentUser); - Task> IGuild.GetUsers() - => Task.FromResult>(Users); - Task IUpdateable.Update() - => Task.CompletedTask; - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Guilds/GuildIntegration.cs b/src/Discord.Net/WebSocket/Entities/Guilds/GuildIntegration.cs deleted file mode 100644 index 61610a0ae..000000000 --- a/src/Discord.Net/WebSocket/Entities/Guilds/GuildIntegration.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using Model = Discord.API.Integration; - -namespace Discord.WebSocket -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class GuildIntegration : IGuildIntegration - { - /// - public ulong Id { get; private set; } - /// - public string Name { get; private set; } - /// - public string Type { get; private set; } - /// - public bool IsEnabled { get; private set; } - /// - public bool IsSyncing { get; private set; } - /// - public ulong ExpireBehavior { get; private set; } - /// - public ulong ExpireGracePeriod { get; private set; } - /// - public DateTime SyncedAt { get; private set; } - - /// - public Guild Guild { get; private set; } - /// - public Role Role { get; private set; } - /// - public GuildUser User { get; private set; } - /// - public IntegrationAccount Account { get; private set; } - internal DiscordClient Discord => Guild.Discord; - - internal GuildIntegration(Guild guild, Model model) - { - Guild = guild; - Update(model); - } - - private void Update(Model model) - { - Id = model.Id; - Name = model.Name; - Type = model.Type; - IsEnabled = model.Enabled; - IsSyncing = model.Syncing; - ExpireBehavior = model.ExpireBehavior; - ExpireGracePeriod = model.ExpireGracePeriod; - SyncedAt = model.SyncedAt; - - Role = Guild.GetRole(model.RoleId); - User = Guild.GetUser(model.User.Id); - } - - /// - public async Task Delete() - { - await Discord.ApiClient.DeleteGuildIntegration(Guild.Id, Id).ConfigureAwait(false); - } - /// - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildIntegrationParams(); - func(args); - await Discord.ApiClient.ModifyGuildIntegration(Guild.Id, Id, args).ConfigureAwait(false); - } - /// - public async Task Sync() - { - await Discord.ApiClient.SyncGuildIntegration(Guild.Id, Id).ConfigureAwait(false); - } - - public override string ToString() => Name; - private string DebuggerDisplay => $"{Name} ({Id}{(IsEnabled ? ", Enabled" : "")})"; - - IGuild IGuildIntegration.Guild => Guild; - IRole IGuildIntegration.Role => Role; - IUser IGuildIntegration.User => User; - IntegrationAccount IGuildIntegration.Account => Account; - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Message.cs b/src/Discord.Net/WebSocket/Entities/Message.cs deleted file mode 100644 index af42298f8..000000000 --- a/src/Discord.Net/WebSocket/Entities/Message.cs +++ /dev/null @@ -1,155 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Message; - -namespace Discord.WebSocket -{ - //TODO: Support mention_roles - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class Message : IMessage - { - /// - public ulong Id { get; } - - /// - public DateTime? EditedTimestamp { get; private set; } - /// - public bool IsTTS { get; private set; } - /// - public string RawText { get; private set; } - /// - public string Text { get; private set; } - /// - public DateTime Timestamp { get; private set; } - - /// - public IMessageChannel Channel { get; } - /// - public IUser Author { get; } - - /// - public IReadOnlyList Attachments { get; private set; } - /// - public IReadOnlyList Embeds { get; private set; } - /// - public IReadOnlyList MentionedUsers { get; private set; } - /// - public IReadOnlyList MentionedChannels { get; private set; } - /// - public IReadOnlyList MentionedRoles { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - internal DiscordClient Discord => (Channel as TextChannel)?.Discord ?? (Channel as DMChannel).Discord; - - internal Message(IMessageChannel channel, IUser author, Model model) - { - Id = model.Id; - Channel = channel; - Author = author; - - Update(model); - } - private void Update(Model model) - { - var guildChannel = Channel as GuildChannel; - var guild = guildChannel?.Guild; - - IsTTS = model.IsTextToSpeech; - Timestamp = model.Timestamp; - EditedTimestamp = model.EditedTimestamp; - RawText = model.Content; - - if (model.Attachments.Length > 0) - { - var attachments = new Attachment[model.Attachments.Length]; - for (int i = 0; i < attachments.Length; i++) - attachments[i] = new Attachment(model.Attachments[i]); - Attachments = ImmutableArray.Create(attachments); - } - else - Attachments = Array.Empty(); - - if (model.Embeds.Length > 0) - { - var embeds = new Embed[model.Attachments.Length]; - for (int i = 0; i < embeds.Length; i++) - embeds[i] = new Embed(model.Embeds[i]); - Embeds = ImmutableArray.Create(embeds); - } - else - Embeds = Array.Empty(); - - if (guildChannel != null && model.Mentions.Length > 0) - { - var builder = ImmutableArray.CreateBuilder(model.Mentions.Length); - for (int i = 0; i < model.Mentions.Length; i++) - { - var user = guild.GetUser(model.Mentions[i].Id); - if (user != null) - builder.Add(user); - } - MentionedUsers = builder.ToArray(); - } - else - MentionedUsers = Array.Empty(); - - if (guildChannel != null/* && model.Content != null*/) - { - MentionedChannels = MentionUtils.GetChannelMentions(model.Content).Select(x => guild.GetChannel(x)).Where(x => x != null).ToImmutableArray(); - - var mentionedRoles = MentionUtils.GetRoleMentions(model.Content).Select(x => guild.GetRole(x)).Where(x => x != null).ToImmutableArray(); - if (model.IsMentioningEveryone) - mentionedRoles = mentionedRoles.Add(guild.EveryoneRole); - MentionedRoles = mentionedRoles; - } - else - { - MentionedChannels = Array.Empty(); - MentionedRoles = Array.Empty(); - } - - Text = MentionUtils.CleanUserMentions(model.Content, model.Mentions); - - //Author.Update(model.Author); //TODO: Uncomment this somehow - } - - /// - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyMessageParams(); - func(args); - var guildChannel = Channel as GuildChannel; - - if (guildChannel != null) - await Discord.ApiClient.ModifyMessage(guildChannel.Guild.Id, Channel.Id, Id, args).ConfigureAwait(false); - else - await Discord.ApiClient.ModifyDMMessage(Channel.Id, Id, args).ConfigureAwait(false); - } - - /// - public async Task Delete() - { - var guildChannel = Channel as GuildChannel; - if (guildChannel != null) - await Discord.ApiClient.DeleteMessage(guildChannel.Id, Channel.Id, Id).ConfigureAwait(false); - else - await Discord.ApiClient.DeleteDMMessage(Channel.Id, Id).ConfigureAwait(false); - } - - public override string ToString() => Text; - private string DebuggerDisplay => $"{Author}: {Text}{(Attachments.Count > 0 ? $" [{Attachments.Count} Attachments]" : "")}"; - - IUser IMessage.Author => Author; - IReadOnlyList IMessage.MentionedUsers => MentionedUsers; - IReadOnlyList IMessage.MentionedChannelIds => MentionedChannels.Select(x => x.Id).ToImmutableArray(); - IReadOnlyList IMessage.MentionedRoleIds => MentionedRoles.Select(x => x.Id).ToImmutableArray(); - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Role.cs b/src/Discord.Net/WebSocket/Entities/Role.cs deleted file mode 100644 index 52bef2b1e..000000000 --- a/src/Discord.Net/WebSocket/Entities/Role.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.Role; - -namespace Discord.WebSocket -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class Role : IRole, IMentionable - { - /// - public ulong Id { get; } - /// Returns the guild this role belongs to. - public Guild Guild { get; } - - /// - public Color Color { get; private set; } - /// - public bool IsHoisted { get; private set; } - /// - public bool IsManaged { get; private set; } - /// - public string Name { get; private set; } - /// - public GuildPermissions Permissions { get; private set; } - /// - public int Position { get; private set; } - - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - /// - public bool IsEveryone => Id == Guild.Id; - /// - public string Mention => MentionUtils.Mention(this); - public IEnumerable Users => Guild.Users.Where(x => x.Roles.Any(y => y.Id == Id)); - internal DiscordClient Discord => Guild.Discord; - - internal Role(Guild guild, Model model) - { - Id = model.Id; - Guild = guild; - - Update(model); - } - internal void Update(Model model) - { - Name = model.Name; - IsHoisted = model.Hoist.Value; - IsManaged = model.Managed.Value; - Position = model.Position.Value; - Color = new Color(model.Color.Value); - Permissions = new GuildPermissions(model.Permissions.Value); - } - /// Modifies the properties of this role. - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildRoleParams(); - func(args); - await Discord.ApiClient.ModifyGuildRole(Guild.Id, Id, args).ConfigureAwait(false); - } - /// Deletes this message. - public async Task Delete() - => await Discord.ApiClient.DeleteGuildRole(Guild.Id, Id).ConfigureAwait(false); - - /// - public override string ToString() => Name; - private string DebuggerDisplay => $"{Name} ({Id})"; - - ulong IRole.GuildId => Guild.Id; - - Task> IRole.GetUsers() - => Task.FromResult>(Users); - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Users/GuildUser.cs b/src/Discord.Net/WebSocket/Entities/Users/GuildUser.cs deleted file mode 100644 index c0caec225..000000000 --- a/src/Discord.Net/WebSocket/Entities/Users/GuildUser.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading.Tasks; -using Model = Discord.API.GuildMember; - -namespace Discord.WebSocket -{ - public class GuildUser : IGuildUser - { - private ImmutableArray _roles; - - public Guild Guild { get; } - public User GlobalUser { get; } - - /// - public bool IsDeaf { get; private set; } - /// - public bool IsMute { get; private set; } - /// - public DateTime JoinedAt { get; private set; } - /// - public string Nickname { get; private set; } - /// - public UserStatus Status { get; private set; } - /// - public Game? CurrentGame { get; private set; } - /// - public VoiceChannel VoiceChannel { get; private set; } - /// - public GuildPermissions GuildPermissions { get; private set; } - - /// - public IReadOnlyList Roles => _roles; - /// - public string AvatarUrl => GlobalUser.AvatarUrl; - /// - public ushort Discriminator => GlobalUser.Discriminator; - /// - public bool IsBot => GlobalUser.IsBot; - /// - public string Username => GlobalUser.Username; - /// - public DateTime CreatedAt => GlobalUser.CreatedAt; - /// - public ulong Id => GlobalUser.Id; - /// - public string Mention => GlobalUser.Mention; - internal DiscordClient Discord => Guild.Discord; - - internal GuildUser(User globalUser, Guild guild, Model model) - { - GlobalUser = globalUser; - Guild = guild; - - globalUser.Update(model.User); - Update(model); - } - internal void Update(Model model) - { - IsDeaf = model.Deaf; - IsMute = model.Mute; - JoinedAt = model.JoinedAt.Value; - Nickname = model.Nick; - - var roles = ImmutableArray.CreateBuilder(model.Roles.Length + 1); - roles.Add(Guild.EveryoneRole); - for (int i = 0; i < model.Roles.Length; i++) - roles.Add(Guild.GetRole(model.Roles[i])); - _roles = roles.ToImmutable(); - - UpdateGuildPermissions(); - } - internal void UpdateGuildPermissions() - { - GuildPermissions = new GuildPermissions(Permissions.ResolveGuild(this)); - } - - public async Task Modify(Action func) - { - if (func == null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyGuildMemberParams(); - func(args); - - bool isCurrentUser = Discord.CurrentUser.Id == Id; - if (isCurrentUser && args.Nickname.IsSpecified) - { - var nickArgs = new ModifyCurrentUserNickParams { Nickname = args.Nickname.Value }; - await Discord.ApiClient.ModifyCurrentUserNick(Guild.Id, nickArgs).ConfigureAwait(false); - args.Nickname = new API.Optional(); //Remove - } - - if (!isCurrentUser || args.Deaf.IsSpecified || args.Mute.IsSpecified || args.Roles.IsSpecified) - { - await Discord.ApiClient.ModifyGuildMember(Guild.Id, Id, args).ConfigureAwait(false); - if (args.Deaf.IsSpecified) - IsDeaf = args.Deaf.Value; - if (args.Mute.IsSpecified) - IsMute = args.Mute.Value; - if (args.Nickname.IsSpecified) - Nickname = args.Nickname.Value; - if (args.Roles.IsSpecified) - _roles = args.Roles.Value.Select(x => Guild.GetRole(x)).Where(x => x != null).ToImmutableArray(); - } - } - - public async Task Kick() - { - await Discord.ApiClient.RemoveGuildMember(Guild.Id, Id).ConfigureAwait(false); - } - - public GuildPermissions GetGuildPermissions() - { - return new GuildPermissions(Permissions.ResolveGuild(this)); - } - public ChannelPermissions GetPermissions(IGuildChannel channel) - { - if (channel == null) throw new ArgumentNullException(nameof(channel)); - return new ChannelPermissions(Permissions.ResolveChannel(this, channel, GuildPermissions.RawValue)); - } - - public async Task CreateDMChannel() - { - return await GlobalUser.CreateDMChannel().ConfigureAwait(false); - } - - - IGuild IGuildUser.Guild => Guild; - IReadOnlyList IGuildUser.Roles => Roles; - IVoiceChannel IGuildUser.VoiceChannel => VoiceChannel; - - ChannelPermissions IGuildUser.GetPermissions(IGuildChannel channel) - => GetPermissions(channel); - async Task IUser.CreateDMChannel() - => await CreateDMChannel().ConfigureAwait(false); - Task IUpdateable.Update() - => Task.CompletedTask; - - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Users/SelfUser.cs b/src/Discord.Net/WebSocket/Entities/Users/SelfUser.cs deleted file mode 100644 index 8b8a86788..000000000 --- a/src/Discord.Net/WebSocket/Entities/Users/SelfUser.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Threading.Tasks; -using Model = Discord.API.User; - -namespace Discord.WebSocket -{ - public class SelfUser : User, ISelfUser - { - /// - public string Email { get; private set; } - /// - public bool IsVerified { get; private set; } - - internal SelfUser(DiscordClient discord, Model model) - : base(discord, model) - { - } - internal override void Update(Model model) - { - base.Update(model); - - Email = model.Email; - IsVerified = model.IsVerified; - } - - /// - public async Task Modify(Action func) - { - if (func != null) throw new NullReferenceException(nameof(func)); - - var args = new ModifyCurrentUserParams(); - func(args); - await Discord.ApiClient.ModifyCurrentUser(args).ConfigureAwait(false); - } - - Task IUpdateable.Update() - => Task.CompletedTask; - } -} diff --git a/src/Discord.Net/WebSocket/Entities/Users/User.cs b/src/Discord.Net/WebSocket/Entities/Users/User.cs deleted file mode 100644 index e507b4df8..000000000 --- a/src/Discord.Net/WebSocket/Entities/Users/User.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using Model = Discord.API.User; - -namespace Discord.WebSocket -{ - //TODO: Unload when there are no more references via DMUser or GuildUser - [DebuggerDisplay("{DebuggerDisplay,nq}")] - public class User : IUser - { - private string _avatarId; - - /// - public ulong Id { get; } - internal DiscordClient Discord { get; } - - /// - public ushort Discriminator { get; private set; } - /// - public bool IsBot { get; private set; } - /// - public string Username { get; private set; } - /// - public DMChannel DMChannel { get; internal set; } - /// - public Game? CurrentGame { get; internal set; } - /// - public UserStatus Status { get; internal set; } - - /// - public string AvatarUrl => API.CDN.GetUserAvatarUrl(Id, _avatarId); - /// - public DateTime CreatedAt => DateTimeUtils.FromSnowflake(Id); - /// - public string Mention => MentionUtils.Mention(this, false); - /// - public string NicknameMention => MentionUtils.Mention(this, true); - - internal User(DiscordClient discord, Model model) - { - Discord = discord; - Id = model.Id; - - Update(model); - } - internal virtual void Update(Model model) - { - _avatarId = model.Avatar; - Discriminator = model.Discriminator; - IsBot = model.Bot; - Username = model.Username; - } - - public async Task CreateDMChannel() - { - var channel = DMChannel; - if (channel == null) - { - var args = new CreateDMChannelParams { RecipientId = Id }; - var model = await Discord.ApiClient.CreateDMChannel(args).ConfigureAwait(false); - - channel = new DMChannel(Discord, this, model); - } - return channel; - } - - public override string ToString() => $"{Username}#{Discriminator}"; - private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id})"; - - /// - async Task IUser.CreateDMChannel() - => await CreateDMChannel().ConfigureAwait(false); - } -} diff --git a/src/Discord.Net/WebSocket/MessageCache.cs b/src/Discord.Net/WebSocket/MessageCache.cs deleted file mode 100644 index 5051efc3a..000000000 --- a/src/Discord.Net/WebSocket/MessageCache.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Discord.API.Rest; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading.Tasks; - -namespace Discord.WebSocket -{ - internal class MessageCache - { - private readonly DiscordClient _discord; - private readonly IMessageChannel _channel; - private readonly ConcurrentDictionary _messages; - private readonly ConcurrentQueue _orderedMessages; - private readonly int _size; - - public IEnumerable Messages => _messages.Select(x => x.Value); - - public MessageCache(DiscordClient discord, IMessageChannel channel) - { - _discord = discord; - _channel = channel; - _size = discord.MessageCacheSize; - _messages = new ConcurrentDictionary(1, (int)(_size * 1.05)); - _orderedMessages = new ConcurrentQueue(); - } - - internal void Add(Message message) - { - if (_messages.TryAdd(message.Id, message)) - { - _orderedMessages.Enqueue(message.Id); - - ulong msgId; - Message msg; - while (_orderedMessages.Count > _size && _orderedMessages.TryDequeue(out msgId)) - _messages.TryRemove(msgId, out msg); - } - } - - internal void Remove(ulong id) - { - Message msg; - _messages.TryRemove(id, out msg); - } - - public Message Get(ulong id) - { - Message result; - if (_messages.TryGetValue(id, out result)) - return result; - return null; - } - public IImmutableList GetMany(ulong? fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - { - if (limit < 0) throw new ArgumentOutOfRangeException(nameof(limit)); - if (limit == 0) return ImmutableArray.Empty; - - IEnumerable cachedMessageIds; - if (fromMessageId == null) - cachedMessageIds = _orderedMessages; - else if (dir == Direction.Before) - cachedMessageIds = _orderedMessages.Where(x => x < fromMessageId.Value); - else - cachedMessageIds = _orderedMessages.Where(x => x > fromMessageId.Value); - - return cachedMessageIds - .Take(limit) - .Select(x => - { - Message msg; - if (_messages.TryGetValue(x, out msg)) - return msg; - return null; - }) - .Where(x => x != null) - .ToImmutableArray(); - } - - public async Task> Download(ulong? fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) - { - //TODO: Test heavily - - if (limit < 0) throw new ArgumentOutOfRangeException(nameof(limit)); - if (limit == 0) return ImmutableArray.Empty; - - var cachedMessages = GetMany(fromMessageId, dir, limit); - if (cachedMessages.Count == limit) - return cachedMessages; - else if (cachedMessages.Count > limit) - return cachedMessages.Skip(cachedMessages.Count - limit); - else - { - var args = new GetChannelMessagesParams - { - Limit = limit - cachedMessages.Count, - RelativeDirection = dir, - RelativeMessageId = dir == Direction.Before ? cachedMessages[0].Id : cachedMessages[cachedMessages.Count - 1].Id - }; - var downloadedMessages = await _discord.ApiClient.GetChannelMessages(_channel.Id, args).ConfigureAwait(false); - //TODO: Ugly channel cast - return cachedMessages.AsEnumerable().Concat(downloadedMessages.Select(x => new Message(_channel, (_channel as Channel).GetUser(x.Id), x))).ToImmutableArray(); - } - } - } -} diff --git a/src/Discord.Net/WebSocket/PermissionsCache.cs b/src/Discord.Net/WebSocket/PermissionsCache.cs deleted file mode 100644 index 30f3a0b6e..000000000 --- a/src/Discord.Net/WebSocket/PermissionsCache.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; - -namespace Discord.WebSocket -{ - internal struct ChannelMember - { - public GuildUser User { get; } - public ChannelPermissions Permissions { get; } - - public ChannelMember(GuildUser user, ChannelPermissions permissions) - { - User = user; - Permissions = permissions; - } - } - - internal class PermissionsCache - { - private readonly GuildChannel _channel; - private readonly ConcurrentDictionary _users; - - public IEnumerable Members => _users.Select(x => x.Value); - - public PermissionsCache(GuildChannel channel) - { - _channel = channel; - _users = new ConcurrentDictionary(1, (int)(_channel.Guild.UserCount * 1.05)); - } - - public ChannelMember? Get(ulong id) - { - ChannelMember member; - if (_users.TryGetValue(id, out member)) - return member; - return null; - } - public void Add(GuildUser user) - { - _users[user.Id] = new ChannelMember(user, new ChannelPermissions(Permissions.ResolveChannel(user, _channel, user.GuildPermissions.RawValue))); - } - public void Remove(GuildUser user) - { - ChannelMember member; - _users.TryRemove(user.Id, out member); - } - - public void UpdateAll() - { - foreach (var pair in _users) - { - var member = pair.Value; - var newPerms = Permissions.ResolveChannel(member.User, _channel, member.User.GuildPermissions.RawValue); - if (newPerms != member.Permissions.RawValue) - _users[pair.Key] = new ChannelMember(member.User, new ChannelPermissions(newPerms)); - } - } - public void Update(GuildUser user) - { - ChannelMember member; - if (_users.TryGetValue(user.Id, out member)) - { - var newPerms = Permissions.ResolveChannel(user, _channel, user.GuildPermissions.RawValue); - if (newPerms != member.Permissions.RawValue) - _users[user.Id] = new ChannelMember(user, new ChannelPermissions(newPerms)); - } - } - } -} diff --git a/src/Discord.Net/project.json b/src/Discord.Net/project.json index a15ef430a..ceac7be0a 100644 --- a/src/Discord.Net/project.json +++ b/src/Discord.Net/project.json @@ -19,11 +19,20 @@ }, "dependencies": { - "NETStandard.Library": "1.5.0-rc2-24027", + "Microsoft.Win32.Primitives": "4.0.1", "Newtonsoft.Json": "8.0.3", - "System.Collections.Immutable": "1.2.0-rc2-24027", - "System.Net.Websockets.Client": "4.0.0-rc2-24027", - "System.Runtime.Serialization.Primitives": "4.1.1-rc2-24027" + "System.Collections.Concurrent": "4.0.12", + "System.Collections.Immutable": "1.2.0", + "System.IO.Compression": "4.1.0", + "System.IO.FileSystem": "4.0.1", + "System.Net.Http": "4.1.0", + "System.Net.NameResolution": "4.0.0", + "System.Net.Sockets": "4.1.0", + "System.Net.WebSockets.Client": "4.0.0", + "System.Reflection.Extensions": "4.0.1", + "System.Runtime.InteropServices": "4.1.0", + "System.Runtime.Serialization.Primitives": "4.1.1", + "System.Text.RegularExpressions": "4.1.0" }, "frameworks": {