| @@ -3,24 +3,24 @@ Microsoft Visual Studio Solution File, Format Version 12.00 | |||||
| # Visual Studio 14 | # Visual Studio 14 | ||||
| VisualStudioVersion = 14.0.25123.0 | VisualStudioVersion = 14.0.25123.0 | ||||
| MinimumVisualStudioVersion = 10.0.40219.1 | 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}" | Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Discord.Net", "src\Discord.Net\Discord.Net.xproj", "{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}" | ||||
| EndProject | 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 | Global | ||||
| GlobalSection(SolutionConfigurationPlatforms) = preSolution | GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| Debug|Any CPU = Debug|Any CPU | Debug|Any CPU = Debug|Any CPU | ||||
| Release|Any CPU = Release|Any CPU | Release|Any CPU = Release|Any CPU | ||||
| EndGlobalSection | EndGlobalSection | ||||
| GlobalSection(ProjectConfigurationPlatforms) = postSolution | 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.ActiveCfg = Debug|Any CPU | ||||
| {91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU | ||||
| {91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Release|Any CPU.Build.0 = 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 | EndGlobalSection | ||||
| GlobalSection(SolutionProperties) = preSolution | GlobalSection(SolutionProperties) = preSolution | ||||
| HideSolutionNode = FALSE | HideSolutionNode = FALSE | ||||
| @@ -1,25 +1,21 @@ | |||||
| # Discord.Net v1.0.0-dev | # Discord.Net v1.0.0-dev | ||||
| [](https://www.nuget.org/packages/Discord.Net) [](https://ci.appveyor.com/project/foxbot/discord-net/) [](https://discord.gg/0SBTUU1wZTYLhAAW) | |||||
| [](https://www.nuget.org/packages/Discord.Net) [](https://ci.appveyor.com/project/foxbot/discord-net/) [](https://discord.gg/0SBTUU1wZTYLhAAW) | [](https://www.nuget.org/packages/Discord.Net) [](https://ci.appveyor.com/project/foxbot/discord-net/) [](https://discord.gg/0SBTUU1wZTYLhAAW) | ||||
| Discord.Net is an API wrapper for [Discord](http://discordapp.com) written in C#. | 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](https://www.nuget.org/packages/Discord.Net/) | ||||
| - [Discord.Net.Commands](https://www.nuget.org/packages/Discord.Net.Commands/) | - [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 | ### 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) | |||||
| 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) | |||||
| @@ -1,6 +1,3 @@ | |||||
| { | { | ||||
| "projects": [ "src", "test" ], | |||||
| "sdk": { | |||||
| "version": "1.0.0-preview1-002702" | |||||
| } | |||||
| "projects": [ "src", "test" ] | |||||
| } | } | ||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| using System; | |||||
| namespace Discord.Commands | |||||
| { | |||||
| [AttributeUsage(AttributeTargets.Parameter)] | |||||
| public class UnparsedAttribute : Attribute | |||||
| { | |||||
| } | |||||
| } | |||||
| @@ -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<IMessage, IReadOnlyList<object>, Task> _action; | |||||
| public string Name { get; } | |||||
| public string Description { get; } | |||||
| public string Text { get; } | |||||
| public Module Module { get; } | |||||
| public IReadOnlyList<CommandParameter> 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<DescriptionAttribute>(); | |||||
| if (description != null) | |||||
| Description = description.Text; | |||||
| Parameters = BuildParameters(methodInfo); | |||||
| _action = BuildAction(methodInfo); | |||||
| } | |||||
| public async Task<ParseResult> 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<ExecuteResult> 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<CommandParameter> BuildParameters(MethodInfo methodInfo) | |||||
| { | |||||
| var parameters = methodInfo.GetParameters(); | |||||
| var paramBuilder = ImmutableArray.CreateBuilder<CommandParameter>(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<UnparsedAttribute>() != 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<DescriptionAttribute>()?.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<IMessage, IReadOnlyList<object>, 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})"; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| namespace Discord.Commands | |||||
| { | |||||
| public enum CommandError | |||||
| { | |||||
| //Search | |||||
| UnknownCommand, | |||||
| //Parse | |||||
| ParseFailed, | |||||
| BadArgCount, | |||||
| //Parse (Type Reader) | |||||
| CastFailed, | |||||
| ObjectNotFound, | |||||
| MultipleMatches, | |||||
| //Execute | |||||
| Exception, | |||||
| } | |||||
| } | |||||
| @@ -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<TypeReaderResult> 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)" : "")}"; | |||||
| } | |||||
| } | |||||
| @@ -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<ParseResult> 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<object>(); | |||||
| 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()); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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<object, Module> _modules; | |||||
| private readonly ConcurrentDictionary<Type, TypeReader> _typeReaders; | |||||
| private readonly CommandMap _map; | |||||
| public IEnumerable<Module> Modules => _modules.Select(x => x.Value); | |||||
| public IEnumerable<Command> Commands => _modules.SelectMany(x => x.Value.Commands); | |||||
| public CommandService() | |||||
| { | |||||
| _moduleLock = new SemaphoreSlim(1, 1); | |||||
| _modules = new ConcurrentDictionary<object, Module>(); | |||||
| _map = new CommandMap(); | |||||
| _typeReaders = new ConcurrentDictionary<Type, TypeReader> | |||||
| { | |||||
| [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<IChannel>(), | |||||
| [typeof(IGuildChannel)] = new ChannelTypeReader<IGuildChannel>(), | |||||
| [typeof(ITextChannel)] = new ChannelTypeReader<ITextChannel>(), | |||||
| [typeof(IVoiceChannel)] = new ChannelTypeReader<IVoiceChannel>(), | |||||
| [typeof(IRole)] = new RoleTypeReader(), | |||||
| [typeof(IUser)] = new UserTypeReader<IUser>(), | |||||
| [typeof(IGuildUser)] = new UserTypeReader<IGuildUser>() | |||||
| }; | |||||
| } | |||||
| public void AddTypeReader<T>(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<Module> 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<ModuleAttribute>(); | |||||
| 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<IEnumerable<Module>> LoadAssembly(Assembly assembly) | |||||
| { | |||||
| var modules = ImmutableArray.CreateBuilder<Module>(); | |||||
| await _moduleLock.WaitAsync().ConfigureAwait(false); | |||||
| try | |||||
| { | |||||
| foreach (var type in assembly.ExportedTypes) | |||||
| { | |||||
| var typeInfo = type.GetTypeInfo(); | |||||
| var moduleAttr = typeInfo.GetCustomAttribute<ModuleAttribute>(); | |||||
| if (moduleAttr != null) | |||||
| { | |||||
| var moduleInstance = ReflectionUtils.CreateObject(typeInfo); | |||||
| modules.Add(LoadInternal(moduleInstance, moduleAttr, typeInfo)); | |||||
| } | |||||
| } | |||||
| return modules.ToImmutable(); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _moduleLock.Release(); | |||||
| } | |||||
| } | |||||
| public async Task<bool> Unload(Module module) | |||||
| { | |||||
| await _moduleLock.WaitAsync().ConfigureAwait(false); | |||||
| try | |||||
| { | |||||
| return UnloadInternal(module.Instance); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _moduleLock.Release(); | |||||
| } | |||||
| } | |||||
| public async Task<bool> 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<IResult> Execute(IMessage message, int argPos) => Execute(message, message.RawText.Substring(argPos)); | |||||
| public async Task<IResult> 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."); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| <?xml version="1.0" encoding="utf-8"?> | |||||
| <Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> | |||||
| <PropertyGroup> | |||||
| <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion> | |||||
| <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath> | |||||
| </PropertyGroup> | |||||
| <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" /> | |||||
| <PropertyGroup Label="Globals"> | |||||
| <ProjectGuid>078dd7e6-943d-4d09-afc2-d2ba58b76c9c</ProjectGuid> | |||||
| <RootNamespace>Discord.Commands</RootNamespace> | |||||
| <BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath> | |||||
| <OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath> | |||||
| <TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion> | |||||
| </PropertyGroup> | |||||
| <PropertyGroup> | |||||
| <SchemaVersion>2.0</SchemaVersion> | |||||
| </PropertyGroup> | |||||
| <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" /> | |||||
| </Project> | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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<string, CommandMapNode> _nodes; | |||||
| public CommandMap() | |||||
| { | |||||
| _nodes = new ConcurrentDictionary<string, CommandMapNode>(); | |||||
| } | |||||
| 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<Command> 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<Command>(); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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<string, CommandMapNode> _nodes; | |||||
| private readonly string _name; | |||||
| private ImmutableArray<Command> _commands; | |||||
| public bool IsEmpty => _commands.Length == 0 && _nodes.Count == 0; | |||||
| public CommandMapNode(string name) | |||||
| { | |||||
| _name = name; | |||||
| _nodes = new ConcurrentDictionary<string, CommandMapNode>(); | |||||
| _commands = ImmutableArray.Create<Command>(); | |||||
| } | |||||
| 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<Command> 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; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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<Command> Commands { get; } | |||||
| internal object Instance { get; } | |||||
| internal Module(CommandService service, object instance, ModuleAttribute moduleAttr, TypeInfo typeInfo) | |||||
| { | |||||
| Service = service; | |||||
| Name = typeInfo.Name; | |||||
| Instance = instance; | |||||
| List<Command> commands = new List<Command>(); | |||||
| SearchClass(instance, commands, typeInfo, moduleAttr.Prefix ?? ""); | |||||
| Commands = commands; | |||||
| } | |||||
| private void SearchClass(object instance, List<Command> commands, TypeInfo typeInfo, string groupPrefix) | |||||
| { | |||||
| if (groupPrefix != "") | |||||
| groupPrefix += " "; | |||||
| foreach (var method in typeInfo.DeclaredMethods) | |||||
| { | |||||
| var cmdAttr = method.GetCustomAttribute<CommandAttribute>(); | |||||
| if (cmdAttr != null) | |||||
| commands.Add(new Command(this, instance, cmdAttr, method, groupPrefix)); | |||||
| } | |||||
| foreach (var type in typeInfo.DeclaredNestedTypes) | |||||
| { | |||||
| var groupAttrib = type.GetCustomAttribute<GroupAttribute>(); | |||||
| 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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,48 @@ | |||||
| using System; | |||||
| using System.Linq; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Commands | |||||
| { | |||||
| internal class ChannelTypeReader<T> : TypeReader | |||||
| where T : class, IChannel | |||||
| { | |||||
| public override async Task<TypeReaderResult> 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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,17 @@ | |||||
| using System; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Commands | |||||
| { | |||||
| internal class GenericTypeReader : TypeReader | |||||
| { | |||||
| private readonly Func<IMessage, string, Task<TypeReaderResult>> _action; | |||||
| public GenericTypeReader(Func<IMessage, string, Task<TypeReaderResult>> action) | |||||
| { | |||||
| _action = action; | |||||
| } | |||||
| public override Task<TypeReaderResult> Read(IMessage context, string input) => _action(context, input); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| using System.Globalization; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Commands | |||||
| { | |||||
| internal class MessageTypeReader : TypeReader | |||||
| { | |||||
| public override Task<TypeReaderResult> 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.")); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| using System; | |||||
| using System.Linq; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Commands | |||||
| { | |||||
| internal class RoleTypeReader : TypeReader | |||||
| { | |||||
| public override Task<TypeReaderResult> 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.")); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Commands | |||||
| { | |||||
| public abstract class TypeReader | |||||
| { | |||||
| public abstract Task<TypeReaderResult> Read(IMessage context, string input); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,62 @@ | |||||
| using System; | |||||
| using System.Linq; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Commands | |||||
| { | |||||
| internal class UserTypeReader<T> : TypeReader | |||||
| where T : class, IUser | |||||
| { | |||||
| public override async Task<TypeReaderResult> 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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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}"; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| namespace Discord.Commands | |||||
| { | |||||
| public interface IResult | |||||
| { | |||||
| CommandError? Error { get; } | |||||
| string ErrorReason { get; } | |||||
| bool IsSuccess { get; } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,35 @@ | |||||
| using System.Collections.Generic; | |||||
| using System.Diagnostics; | |||||
| namespace Discord.Commands | |||||
| { | |||||
| [DebuggerDisplay(@"{DebuggerDisplay,nq}")] | |||||
| public struct ParseResult : IResult | |||||
| { | |||||
| public IReadOnlyList<object> Values { get; } | |||||
| public CommandError? Error { get; } | |||||
| public string ErrorReason { get; } | |||||
| public bool IsSuccess => !Error.HasValue; | |||||
| private ParseResult(IReadOnlyList<object> values, CommandError? error, string errorReason) | |||||
| { | |||||
| Values = values; | |||||
| Error = error; | |||||
| ErrorReason = errorReason; | |||||
| } | |||||
| internal static ParseResult FromSuccess(IReadOnlyList<object> 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}"; | |||||
| } | |||||
| } | |||||
| @@ -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<Command> Commands { get; } | |||||
| public CommandError? Error { get; } | |||||
| public string ErrorReason { get; } | |||||
| public bool IsSuccess => !Error.HasValue; | |||||
| private SearchResult(string text, IReadOnlyList<Command> commands, CommandError? error, string errorReason) | |||||
| { | |||||
| Text = text; | |||||
| Commands = commands; | |||||
| Error = error; | |||||
| ErrorReason = errorReason; | |||||
| } | |||||
| internal static SearchResult FromSuccess(string text, IReadOnlyList<Command> 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}"; | |||||
| } | |||||
| } | |||||
| @@ -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}"; | |||||
| } | |||||
| } | |||||
| @@ -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" | |||||
| ] | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -15,8 +15,8 @@ namespace Discord.API | |||||
| [JsonProperty("proxy_url")] | [JsonProperty("proxy_url")] | ||||
| public string ProxyUrl { get; set; } | public string ProxyUrl { get; set; } | ||||
| [JsonProperty("height")] | [JsonProperty("height")] | ||||
| public int? Height { get; set; } | |||||
| public Optional<int> Height { get; set; } | |||||
| [JsonProperty("width")] | [JsonProperty("width")] | ||||
| public int? Width { get; set; } | |||||
| public Optional<int> Width { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -14,28 +14,28 @@ namespace Discord.API | |||||
| //GuildChannel | //GuildChannel | ||||
| [JsonProperty("guild_id")] | [JsonProperty("guild_id")] | ||||
| public ulong? GuildId { get; set; } | |||||
| public Optional<ulong> GuildId { get; set; } | |||||
| [JsonProperty("name")] | [JsonProperty("name")] | ||||
| public string Name { get; set; } | |||||
| public Optional<string> Name { get; set; } | |||||
| [JsonProperty("type")] | [JsonProperty("type")] | ||||
| public ChannelType Type { get; set; } | |||||
| public Optional<ChannelType> Type { get; set; } | |||||
| [JsonProperty("position")] | [JsonProperty("position")] | ||||
| public int Position { get; set; } | |||||
| public Optional<int> Position { get; set; } | |||||
| [JsonProperty("permission_overwrites")] | [JsonProperty("permission_overwrites")] | ||||
| public Overwrite[] PermissionOverwrites { get; set; } | |||||
| public Optional<Overwrite[]> PermissionOverwrites { get; set; } | |||||
| //TextChannel | //TextChannel | ||||
| [JsonProperty("topic")] | [JsonProperty("topic")] | ||||
| public string Topic { get; set; } | |||||
| public Optional<string> Topic { get; set; } | |||||
| //VoiceChannel | //VoiceChannel | ||||
| [JsonProperty("bitrate")] | [JsonProperty("bitrate")] | ||||
| public int Bitrate { get; set; } | |||||
| public Optional<int> Bitrate { get; set; } | |||||
| [JsonProperty("user_limit")] | [JsonProperty("user_limit")] | ||||
| public int UserLimit { get; set; } | |||||
| public Optional<int> UserLimit { get; set; } | |||||
| //DMChannel | //DMChannel | ||||
| [JsonProperty("recipient")] | [JsonProperty("recipient")] | ||||
| public User Recipient { get; set; } | |||||
| public Optional<User> Recipient { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -15,6 +15,6 @@ namespace Discord.API | |||||
| public bool Revoked { get; set; } | public bool Revoked { get; set; } | ||||
| [JsonProperty("integrations")] | [JsonProperty("integrations")] | ||||
| public IEnumerable<ulong> Integrations { get; set; } | |||||
| public IReadOnlyCollection<ulong> Integrations { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -13,8 +13,8 @@ namespace Discord.API | |||||
| [JsonProperty("url")] | [JsonProperty("url")] | ||||
| public string Url { get; set; } | public string Url { get; set; } | ||||
| [JsonProperty("thumbnail")] | [JsonProperty("thumbnail")] | ||||
| public EmbedThumbnail Thumbnail { get; set; } | |||||
| public Optional<EmbedThumbnail> Thumbnail { get; set; } | |||||
| [JsonProperty("provider")] | [JsonProperty("provider")] | ||||
| public EmbedProvider Provider { get; set; } | |||||
| public Optional<EmbedProvider> Provider { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -9,8 +9,8 @@ namespace Discord.API | |||||
| [JsonProperty("proxy_url")] | [JsonProperty("proxy_url")] | ||||
| public string ProxyUrl { get; set; } | public string ProxyUrl { get; set; } | ||||
| [JsonProperty("height")] | [JsonProperty("height")] | ||||
| public int? Height { get; set; } | |||||
| public Optional<int> Height { get; set; } | |||||
| [JsonProperty("width")] | [JsonProperty("width")] | ||||
| public int? Width { get; set; } | |||||
| public Optional<int> Width { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -7,8 +7,8 @@ namespace Discord.API | |||||
| [JsonProperty("name")] | [JsonProperty("name")] | ||||
| public string Name { get; set; } | public string Name { get; set; } | ||||
| [JsonProperty("url")] | [JsonProperty("url")] | ||||
| public string StreamUrl { get; set; } | |||||
| public Optional<string> StreamUrl { get; set; } | |||||
| [JsonProperty("type")] | [JsonProperty("type")] | ||||
| public StreamType StreamType { get; set; } | |||||
| public Optional<StreamType?> StreamType { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -25,7 +25,7 @@ namespace Discord.API | |||||
| [JsonProperty("embed_channel_id")] | [JsonProperty("embed_channel_id")] | ||||
| public ulong? EmbedChannelId { get; set; } | public ulong? EmbedChannelId { get; set; } | ||||
| [JsonProperty("verification_level")] | [JsonProperty("verification_level")] | ||||
| public int VerificationLevel { get; set; } | |||||
| public VerificationLevel VerificationLevel { get; set; } | |||||
| [JsonProperty("voice_states")] | [JsonProperty("voice_states")] | ||||
| public VoiceState[] VoiceStates { get; set; } | public VoiceState[] VoiceStates { get; set; } | ||||
| [JsonProperty("roles")] | [JsonProperty("roles")] | ||||
| @@ -34,5 +34,9 @@ namespace Discord.API | |||||
| public Emoji[] Emojis { get; set; } | public Emoji[] Emojis { get; set; } | ||||
| [JsonProperty("features")] | [JsonProperty("features")] | ||||
| public string[] Features { get; set; } | public string[] Features { get; set; } | ||||
| [JsonProperty("mfa_level")] | |||||
| public MfaLevel MfaLevel { get; set; } | |||||
| [JsonProperty("default_message_notifications")] | |||||
| public DefaultMessageNotifications DefaultMessageNotifications { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -7,6 +7,6 @@ namespace Discord.API | |||||
| [JsonProperty("enabled")] | [JsonProperty("enabled")] | ||||
| public bool Enabled { get; set; } | public bool Enabled { get; set; } | ||||
| [JsonProperty("channel_id")] | [JsonProperty("channel_id")] | ||||
| public ulong? ChannelId { get; set; } | |||||
| public ulong ChannelId { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -8,11 +8,11 @@ namespace Discord.API | |||||
| [JsonProperty("user")] | [JsonProperty("user")] | ||||
| public User User { get; set; } | public User User { get; set; } | ||||
| [JsonProperty("nick")] | [JsonProperty("nick")] | ||||
| public string Nick { get; set; } | |||||
| public Optional<string> Nick { get; set; } | |||||
| [JsonProperty("roles")] | [JsonProperty("roles")] | ||||
| public ulong[] Roles { get; set; } | public ulong[] Roles { get; set; } | ||||
| [JsonProperty("joined_at")] | [JsonProperty("joined_at")] | ||||
| public DateTime?JoinedAt { get; set; } | |||||
| public DateTimeOffset JoinedAt { get; set; } | |||||
| [JsonProperty("deaf")] | [JsonProperty("deaf")] | ||||
| public bool Deaf { get; set; } | public bool Deaf { get; set; } | ||||
| [JsonProperty("mute")] | [JsonProperty("mute")] | ||||
| @@ -26,6 +26,6 @@ namespace Discord.API | |||||
| [JsonProperty("account")] | [JsonProperty("account")] | ||||
| public IntegrationAccount Account { get; set; } | public IntegrationAccount Account { get; set; } | ||||
| [JsonProperty("synced_at")] | [JsonProperty("synced_at")] | ||||
| public DateTime SyncedAt { get; set; } | |||||
| public DateTimeOffset SyncedAt { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -16,7 +16,7 @@ namespace Discord.API | |||||
| [JsonProperty("temporary")] | [JsonProperty("temporary")] | ||||
| public bool Temporary { get; set; } | public bool Temporary { get; set; } | ||||
| [JsonProperty("created_at")] | [JsonProperty("created_at")] | ||||
| public DateTime CreatedAt { get; set; } | |||||
| public DateTimeOffset CreatedAt { get; set; } | |||||
| [JsonProperty("revoked")] | [JsonProperty("revoked")] | ||||
| public bool Revoked { get; set; } | public bool Revoked { get; set; } | ||||
| } | } | ||||
| @@ -10,24 +10,24 @@ namespace Discord.API | |||||
| [JsonProperty("channel_id")] | [JsonProperty("channel_id")] | ||||
| public ulong ChannelId { get; set; } | public ulong ChannelId { get; set; } | ||||
| [JsonProperty("author")] | [JsonProperty("author")] | ||||
| public User Author { get; set; } | |||||
| public Optional<User> Author { get; set; } | |||||
| [JsonProperty("content")] | [JsonProperty("content")] | ||||
| public string Content { get; set; } | |||||
| public Optional<string> Content { get; set; } | |||||
| [JsonProperty("timestamp")] | [JsonProperty("timestamp")] | ||||
| public DateTime Timestamp { get; set; } | |||||
| public Optional<DateTimeOffset> Timestamp { get; set; } | |||||
| [JsonProperty("edited_timestamp")] | [JsonProperty("edited_timestamp")] | ||||
| public DateTime? EditedTimestamp { get; set; } | |||||
| public Optional<DateTimeOffset?> EditedTimestamp { get; set; } | |||||
| [JsonProperty("tts")] | [JsonProperty("tts")] | ||||
| public bool IsTextToSpeech { get; set; } | |||||
| public Optional<bool> IsTextToSpeech { get; set; } | |||||
| [JsonProperty("mention_everyone")] | [JsonProperty("mention_everyone")] | ||||
| public bool IsMentioningEveryone { get; set; } | |||||
| public Optional<bool> MentionEveryone { get; set; } | |||||
| [JsonProperty("mentions")] | [JsonProperty("mentions")] | ||||
| public User[] Mentions { get; set; } | |||||
| public Optional<User[]> Mentions { get; set; } | |||||
| [JsonProperty("attachments")] | [JsonProperty("attachments")] | ||||
| public Attachment[] Attachments { get; set; } | |||||
| public Optional<Attachment[]> Attachments { get; set; } | |||||
| [JsonProperty("embeds")] | [JsonProperty("embeds")] | ||||
| public Embed[] Embeds { get; set; } | |||||
| [JsonProperty("nonce")] | |||||
| public uint? Nonce { get; set; } | |||||
| public Optional<Embed[]> Embeds { get; set; } | |||||
| [JsonProperty("pinned")] | |||||
| public Optional<bool> Pinned { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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<ulong> GuildId { get; set; } | |||||
| [JsonProperty("status")] | |||||
| public UserStatus Status { get; set; } | |||||
| [JsonProperty("game")] | |||||
| public Game Game { get; set; } | |||||
| [JsonProperty("roles")] | |||||
| public Optional<ulong[]> Roles { get; set; } | |||||
| [JsonProperty("nick")] | |||||
| public Optional<string> Nick { get; set; } | |||||
| } | |||||
| } | |||||
| @@ -9,6 +9,6 @@ namespace Discord.API | |||||
| [JsonProperty("mention_count")] | [JsonProperty("mention_count")] | ||||
| public int MentionCount { get; set; } | public int MentionCount { get; set; } | ||||
| [JsonProperty("last_message_id")] | [JsonProperty("last_message_id")] | ||||
| public ulong? LastMessageId { get; set; } | |||||
| public Optional<ulong> LastMessageId { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| namespace Discord.API | |||||
| { | |||||
| public enum RelationshipType | |||||
| { | |||||
| Friend = 1, | |||||
| Blocked = 2, | |||||
| Pending = 4 | |||||
| } | |||||
| } | |||||
| @@ -9,14 +9,14 @@ namespace Discord.API | |||||
| [JsonProperty("name")] | [JsonProperty("name")] | ||||
| public string Name { get; set; } | public string Name { get; set; } | ||||
| [JsonProperty("color")] | [JsonProperty("color")] | ||||
| public uint? Color { get; set; } | |||||
| public uint Color { get; set; } | |||||
| [JsonProperty("hoist")] | [JsonProperty("hoist")] | ||||
| public bool? Hoist { get; set; } | |||||
| public bool Hoist { get; set; } | |||||
| [JsonProperty("position")] | [JsonProperty("position")] | ||||
| public int? Position { get; set; } | |||||
| public int Position { get; set; } | |||||
| [JsonProperty("permissions"), Int53] | [JsonProperty("permissions"), Int53] | ||||
| public ulong? Permissions { get; set; } | |||||
| public ulong Permissions { get; set; } | |||||
| [JsonProperty("managed")] | [JsonProperty("managed")] | ||||
| public bool? Managed { get; set; } | |||||
| public bool Managed { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -9,14 +9,18 @@ namespace Discord.API | |||||
| [JsonProperty("username")] | [JsonProperty("username")] | ||||
| public string Username { get; set; } | public string Username { get; set; } | ||||
| [JsonProperty("discriminator")] | [JsonProperty("discriminator")] | ||||
| public ushort Discriminator { get; set; } | |||||
| public string Discriminator { get; set; } | |||||
| [JsonProperty("bot")] | |||||
| public bool Bot { get; set; } | |||||
| [JsonProperty("avatar")] | [JsonProperty("avatar")] | ||||
| public string Avatar { get; set; } | public string Avatar { get; set; } | ||||
| //CurrentUser | |||||
| [JsonProperty("verified")] | [JsonProperty("verified")] | ||||
| public bool IsVerified { get; set; } | |||||
| public bool Verified { get; set; } | |||||
| [JsonProperty("email")] | [JsonProperty("email")] | ||||
| public string Email { get; set; } | public string Email { get; set; } | ||||
| [JsonProperty("bot")] | |||||
| public bool Bot { get; set; } | |||||
| [JsonProperty("mfa_enabled")] | |||||
| public bool MfaEnabled { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -7,7 +7,7 @@ namespace Discord.API | |||||
| [JsonProperty("guild_id")] | [JsonProperty("guild_id")] | ||||
| public ulong? GuildId { get; set; } | public ulong? GuildId { get; set; } | ||||
| [JsonProperty("channel_id")] | [JsonProperty("channel_id")] | ||||
| public ulong ChannelId { get; set; } | |||||
| public ulong? ChannelId { get; set; } | |||||
| [JsonProperty("user_id")] | [JsonProperty("user_id")] | ||||
| public ulong UserId { get; set; } | public ulong UserId { get; set; } | ||||
| [JsonProperty("session_id")] | [JsonProperty("session_id")] | ||||
| @@ -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<string, string, double, Task> SentRequest { add { _sentRequestEvent.Add(value); } remove { _sentRequestEvent.Remove(value); } } | |||||
| private readonly AsyncEvent<Func<string, string, double, Task>> _sentRequestEvent = new AsyncEvent<Func<string, string, double, Task>>(); | |||||
| public event Func<VoiceOpCode, Task> SentGatewayMessage { add { _sentGatewayMessageEvent.Add(value); } remove { _sentGatewayMessageEvent.Remove(value); } } | |||||
| private readonly AsyncEvent<Func<VoiceOpCode, Task>> _sentGatewayMessageEvent = new AsyncEvent<Func<VoiceOpCode, Task>>(); | |||||
| public event Func<Task> SentDiscovery { add { _sentDiscoveryEvent.Add(value); } remove { _sentDiscoveryEvent.Remove(value); } } | |||||
| private readonly AsyncEvent<Func<Task>> _sentDiscoveryEvent = new AsyncEvent<Func<Task>>(); | |||||
| public event Func<VoiceOpCode, object, Task> ReceivedEvent { add { _receivedEvent.Add(value); } remove { _receivedEvent.Remove(value); } } | |||||
| private readonly AsyncEvent<Func<VoiceOpCode, object, Task>> _receivedEvent = new AsyncEvent<Func<VoiceOpCode, object, Task>>(); | |||||
| public event Func<byte[], Task> ReceivedPacket { add { _receivedPacketEvent.Add(value); } remove { _receivedPacketEvent.Remove(value); } } | |||||
| private readonly AsyncEvent<Func<byte[], Task>> _receivedPacketEvent = new AsyncEvent<Func<byte[], Task>>(); | |||||
| public event Func<Exception, Task> Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } } | |||||
| private readonly AsyncEvent<Func<Exception, Task>> _disconnectedEvent = new AsyncEvent<Func<Exception, Task>>(); | |||||
| 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<WebSocketMessage>(reader.ReadToEnd()); | |||||
| await _receivedEvent.InvokeAsync((VoiceOpCode)msg.Operation, msg.Payload).ConfigureAwait(false); | |||||
| } | |||||
| } | |||||
| }; | |||||
| _webSocketClient.TextMessage += async text => | |||||
| { | |||||
| var msg = JsonConvert.DeserializeObject<WebSocketMessage>(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<T>(Stream jsonStream) | |||||
| { | |||||
| using (TextReader text = new StreamReader(jsonStream)) | |||||
| using (JsonReader reader = new JsonTextReader(text)) | |||||
| return _serializer.Deserialize<T>(reader); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -1,6 +1,6 @@ | |||||
| namespace Discord.API.Gateway | namespace Discord.API.Gateway | ||||
| { | { | ||||
| public enum GatewayOpCodes : byte | |||||
| public enum GatewayOpCode : byte | |||||
| { | { | ||||
| /// <summary> C←S - Used to send most events. </summary> | /// <summary> C←S - Used to send most events. </summary> | ||||
| Dispatch = 0, | Dispatch = 0, | ||||
| @@ -12,13 +12,21 @@ | |||||
| StatusUpdate = 3, | StatusUpdate = 3, | ||||
| /// <summary> C→S - Used to join a particular voice channel. </summary> | /// <summary> C→S - Used to join a particular voice channel. </summary> | ||||
| VoiceStateUpdate = 4, | VoiceStateUpdate = 4, | ||||
| /// <summary> C→S - Used to ensure the server's voice server is alive. Only send this if voice connection fails or suddenly drops. </summary> | |||||
| /// <summary> C→S - Used to ensure the guild's voice server is alive. </summary> | |||||
| VoiceServerPing = 5, | VoiceServerPing = 5, | ||||
| /// <summary> C→S - Used to resume a connection after a redirect occurs. </summary> | /// <summary> C→S - Used to resume a connection after a redirect occurs. </summary> | ||||
| Resume = 6, | Resume = 6, | ||||
| /// <summary> C←S - Used to notify a client that they must reconnect to another gateway. </summary> | /// <summary> C←S - Used to notify a client that they must reconnect to another gateway. </summary> | ||||
| Reconnect = 7, | Reconnect = 7, | ||||
| /// <summary> C→S - Used to request all members that were withheld by large_threshold </summary> | |||||
| RequestGuildMembers = 8 | |||||
| /// <summary> C→S - Used to request members that were withheld by large_threshold </summary> | |||||
| RequestGuildMembers = 8, | |||||
| /// <summary> C←S - Used to notify the client that their session has expired and cannot be resumed. </summary> | |||||
| InvalidSession = 9, | |||||
| /// <summary> C←S - Used to provide information to the client immediately on connection. </summary> | |||||
| Hello = 10, | |||||
| /// <summary> C←S - Used to reply to a client's heartbeat. </summary> | |||||
| HeartbeatAck = 11, | |||||
| /// <summary> C→S - Used to request presence updates from particular guilds. </summary> | |||||
| GuildSync = 12 | |||||
| } | } | ||||
| } | } | ||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| using Newtonsoft.Json; | |||||
| namespace Discord.API.Gateway | |||||
| { | |||||
| public class GuildMemberAddEvent : GuildMember | |||||
| { | |||||
| [JsonProperty("guild_id")] | |||||
| public ulong GuildId { get; set; } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| using Newtonsoft.Json; | |||||
| namespace Discord.API.Gateway | |||||
| { | |||||
| public class GuildMemberUpdateEvent : GuildMember | |||||
| { | |||||
| [JsonProperty("guild_id")] | |||||
| public ulong GuildId { get; set; } | |||||
| } | |||||
| } | |||||
| @@ -7,6 +7,6 @@ namespace Discord.API.Gateway | |||||
| [JsonProperty("guild_id")] | [JsonProperty("guild_id")] | ||||
| public ulong GuildId { get; set; } | public ulong GuildId { get; set; } | ||||
| [JsonProperty("role")] | [JsonProperty("role")] | ||||
| public Role Data { get; set; } | |||||
| public Role Role { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -7,6 +7,6 @@ namespace Discord.API.Gateway | |||||
| [JsonProperty("guild_id")] | [JsonProperty("guild_id")] | ||||
| public ulong GuildId { get; set; } | public ulong GuildId { get; set; } | ||||
| [JsonProperty("role")] | [JsonProperty("role")] | ||||
| public Role Data { get; set; } | |||||
| public Role Role { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| using Newtonsoft.Json; | |||||
| namespace Discord.API.Gateway | |||||
| { | |||||
| public class HelloEvent | |||||
| { | |||||
| [JsonProperty("heartbeat_interval")] | |||||
| public int HeartbeatInterval { get; set; } | |||||
| } | |||||
| } | |||||
| @@ -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<ulong> Ids { get; set; } | |||||
| } | |||||
| } | |||||
| @@ -23,18 +23,15 @@ namespace Discord.API.Gateway | |||||
| [JsonProperty("read_state")] | [JsonProperty("read_state")] | ||||
| public ReadState[] ReadStates { get; set; } | public ReadState[] ReadStates { get; set; } | ||||
| [JsonProperty("guilds")] | [JsonProperty("guilds")] | ||||
| public Guild[] Guilds { get; set; } | |||||
| public ExtendedGuild[] Guilds { get; set; } | |||||
| [JsonProperty("private_channels")] | [JsonProperty("private_channels")] | ||||
| public Channel[] PrivateChannels { get; set; } | public Channel[] PrivateChannels { get; set; } | ||||
| [JsonProperty("heartbeat_interval")] | |||||
| public int HeartbeatInterval { get; set; } | |||||
| [JsonProperty("relationships")] | |||||
| public Relationship[] Relationships { get; set; } | |||||
| //Ignored | //Ignored | ||||
| [JsonProperty("user_settings")] | |||||
| public object UserSettings { get; set; } | |||||
| /*[JsonProperty("user_settings")] | |||||
| [JsonProperty("user_guild_settings")] | [JsonProperty("user_guild_settings")] | ||||
| public object UserGuildSettings { get; set; } | |||||
| [JsonProperty("tutorial")] | |||||
| public object Tutorial { get; set; } | |||||
| [JsonProperty("tutorial")]*/ | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,14 +1,19 @@ | |||||
| using Newtonsoft.Json; | using Newtonsoft.Json; | ||||
| using System.Collections.Generic; | |||||
| using System.Linq; | |||||
| namespace Discord.API.Gateway | namespace Discord.API.Gateway | ||||
| { | { | ||||
| public class RequestMembersParams | public class RequestMembersParams | ||||
| { | { | ||||
| [JsonProperty("guild_id")] | |||||
| public ulong[] GuildId { get; set; } | |||||
| [JsonProperty("query")] | [JsonProperty("query")] | ||||
| public string Query { get; set; } | public string Query { get; set; } | ||||
| [JsonProperty("limit")] | [JsonProperty("limit")] | ||||
| public int Limit { get; set; } | public int Limit { get; set; } | ||||
| [JsonProperty("guild_id")] | |||||
| public IEnumerable<ulong> GuildIds { get; set; } | |||||
| [JsonIgnore] | |||||
| public IEnumerable<IGuild> Guilds { set { GuildIds = value.Select(x => x.Id); } } | |||||
| } | } | ||||
| } | } | ||||
| @@ -7,6 +7,6 @@ namespace Discord.API.Gateway | |||||
| [JsonProperty("session_id")] | [JsonProperty("session_id")] | ||||
| public string SessionId { get; set; } | public string SessionId { get; set; } | ||||
| [JsonProperty("seq")] | [JsonProperty("seq")] | ||||
| public uint Sequence { get; set; } | |||||
| public int Sequence { get; set; } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -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; } } | |||||
| } | |||||
| } | |||||
| @@ -1,8 +0,0 @@ | |||||
| namespace Discord.API | |||||
| { | |||||
| public interface IOptional | |||||
| { | |||||
| object Value { get; } | |||||
| bool IsSpecified { get; } | |||||
| } | |||||
| } | |||||
| @@ -6,5 +6,7 @@ namespace Discord.API.Rest | |||||
| { | { | ||||
| [JsonProperty("recipient_id")] | [JsonProperty("recipient_id")] | ||||
| public ulong RecipientId { get; set; } | public ulong RecipientId { get; set; } | ||||
| [JsonIgnore] | |||||
| public IUser Recipient { set { RecipientId = value.Id; } } | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,11 +1,14 @@ | |||||
| using Newtonsoft.Json; | using Newtonsoft.Json; | ||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||
| using System.Linq; | |||||
| namespace Discord.API.Rest | namespace Discord.API.Rest | ||||
| { | { | ||||
| public class DeleteMessagesParam | |||||
| public class DeleteMessagesParams | |||||
| { | { | ||||
| [JsonProperty("messages")] | [JsonProperty("messages")] | ||||
| public IEnumerable<ulong> MessageIds { get; set; } | public IEnumerable<ulong> MessageIds { get; set; } | ||||
| [JsonIgnore] | |||||
| public IEnumerable<IMessage> Messages { set { MessageIds = value.Select(x => x.Id); } } | |||||
| } | } | ||||
| } | } | ||||
| @@ -6,5 +6,6 @@ | |||||
| public Direction RelativeDirection { get; set; } = Direction.Before; | public Direction RelativeDirection { get; set; } = Direction.Before; | ||||
| public Optional<ulong> RelativeMessageId { get; set; } | public Optional<ulong> RelativeMessageId { get; set; } | ||||
| public Optional<IMessage> RelativeMessage { set { RelativeMessageId = value.IsSpecified ? value.Value.Id : Optional.Create<ulong>(); } } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -1,10 +0,0 @@ | |||||
| using Newtonsoft.Json; | |||||
| namespace Discord.API.Rest | |||||
| { | |||||
| public class LoginResponse | |||||
| { | |||||
| [JsonProperty("token")] | |||||
| public string Token { get; set; } | |||||
| } | |||||
| } | |||||
| @@ -1,5 +1,4 @@ | |||||
| using Discord.Net.Converters; | |||||
| using Newtonsoft.Json; | |||||
| using Newtonsoft.Json; | |||||
| using System.IO; | using System.IO; | ||||
| namespace Discord.API.Rest | namespace Discord.API.Rest | ||||
| @@ -8,12 +7,6 @@ namespace Discord.API.Rest | |||||
| { | { | ||||
| [JsonProperty("username")] | [JsonProperty("username")] | ||||
| public Optional<string> Username { get; set; } | public Optional<string> Username { get; set; } | ||||
| [JsonProperty("email")] | |||||
| public Optional<string> Email { get; set; } | |||||
| [JsonProperty("password")] | |||||
| public Optional<string> Password { get; set; } | |||||
| [JsonProperty("new_password")] | |||||
| public Optional<string> NewPassword { get; set; } | |||||
| [JsonProperty("avatar"), Image] | [JsonProperty("avatar"), Image] | ||||
| public Optional<Stream> Avatar { get; set; } | public Optional<Stream> Avatar { get; set; } | ||||
| } | } | ||||
| @@ -1,5 +1,4 @@ | |||||
| using Discord.Net.Converters; | |||||
| using Newtonsoft.Json; | |||||
| using Newtonsoft.Json; | |||||
| namespace Discord.API.Rest | namespace Discord.API.Rest | ||||
| { | { | ||||
| @@ -7,7 +6,10 @@ namespace Discord.API.Rest | |||||
| { | { | ||||
| [JsonProperty("enabled")] | [JsonProperty("enabled")] | ||||
| public Optional<bool> Enabled { get; set; } | public Optional<bool> Enabled { get; set; } | ||||
| [JsonProperty("channel")] | [JsonProperty("channel")] | ||||
| public Optional<IVoiceChannel> Channel { get; set; } | |||||
| public Optional<ulong> ChannelId { get; set; } | |||||
| [JsonIgnore] | |||||
| public Optional<IVoiceChannel> Channel { set { ChannelId = value.IsSpecified ? value.Value.Id : Optional.Create<ulong>(); } } | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,18 +1,26 @@ | |||||
| using Newtonsoft.Json; | using Newtonsoft.Json; | ||||
| using System.Collections.Generic; | |||||
| using System.Linq; | |||||
| namespace Discord.API.Rest | namespace Discord.API.Rest | ||||
| { | { | ||||
| public class ModifyGuildMemberParams | public class ModifyGuildMemberParams | ||||
| { | { | ||||
| [JsonProperty("roles")] | |||||
| public Optional<ulong[]> Roles { get; set; } | |||||
| [JsonProperty("mute")] | [JsonProperty("mute")] | ||||
| public Optional<bool> Mute { get; set; } | public Optional<bool> Mute { get; set; } | ||||
| [JsonProperty("deaf")] | [JsonProperty("deaf")] | ||||
| public Optional<bool> Deaf { get; set; } | public Optional<bool> Deaf { get; set; } | ||||
| [JsonProperty("nick")] | [JsonProperty("nick")] | ||||
| public Optional<string> Nickname { get; set; } | public Optional<string> Nickname { get; set; } | ||||
| [JsonProperty("roles")] | |||||
| public Optional<IEnumerable<ulong>> RoleIds { get; set; } | |||||
| [JsonIgnore] | |||||
| public Optional<IEnumerable<IRole>> Roles { set { RoleIds = value.IsSpecified ? Optional.Create(value.Value.Select(x => x.Id)) : Optional.Create<IEnumerable<ulong>>(); } } | |||||
| [JsonProperty("channel_id")] | [JsonProperty("channel_id")] | ||||
| public Optional<IVoiceChannel> VoiceChannel { get; set; } | |||||
| public Optional<ulong> VoiceChannelId { get; set; } | |||||
| [JsonIgnore] | |||||
| public Optional<IVoiceChannel> VoiceChannel { set { VoiceChannelId = value.IsSpecified ? value.Value.Id : Optional.Create<ulong>(); } } | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,5 +1,4 @@ | |||||
| using Discord.Net.Converters; | |||||
| using Newtonsoft.Json; | |||||
| using Newtonsoft.Json; | |||||
| using System.IO; | using System.IO; | ||||
| namespace Discord.API.Rest | namespace Discord.API.Rest | ||||
| @@ -11,16 +10,24 @@ namespace Discord.API.Rest | |||||
| [JsonProperty("region")] | [JsonProperty("region")] | ||||
| public Optional<IVoiceRegion> Region { get; set; } | public Optional<IVoiceRegion> Region { get; set; } | ||||
| [JsonProperty("verification_level")] | [JsonProperty("verification_level")] | ||||
| public Optional<int> VerificationLevel { get; set; } | |||||
| [JsonProperty("afk_channel_id")] | |||||
| public Optional<ulong?> AFKChannelId { get; set; } | |||||
| public Optional<VerificationLevel> VerificationLevel { get; set; } | |||||
| [JsonProperty("default_message_notifications")] | |||||
| public Optional<DefaultMessageNotifications> DefaultMessageNotifications { get; set; } | |||||
| [JsonProperty("afk_timeout")] | [JsonProperty("afk_timeout")] | ||||
| public Optional<int> AFKTimeout { get; set; } | public Optional<int> AFKTimeout { get; set; } | ||||
| [JsonProperty("icon"), Image] | [JsonProperty("icon"), Image] | ||||
| public Optional<Stream> Icon { get; set; } | public Optional<Stream> Icon { get; set; } | ||||
| [JsonProperty("owner_id")] | |||||
| public Optional<GuildMember> Owner { get; set; } | |||||
| [JsonProperty("splash"), Image] | [JsonProperty("splash"), Image] | ||||
| public Optional<Stream> Splash { get; set; } | public Optional<Stream> Splash { get; set; } | ||||
| [JsonProperty("afk_channel_id")] | |||||
| public Optional<ulong?> AFKChannelId { get; set; } | |||||
| [JsonIgnore] | |||||
| public Optional<IVoiceChannel> AFKChannel { set { OwnerId = value.IsSpecified ? value.Value.Id : Optional.Create<ulong>(); } } | |||||
| [JsonProperty("owner_id")] | |||||
| public Optional<ulong> OwnerId { get; set; } | |||||
| [JsonIgnore] | |||||
| public Optional<IGuildUser> Owner { set { OwnerId = value.IsSpecified ? value.Value.Id : Optional.Create<ulong>(); } } | |||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,8 @@ | |||||
| namespace Discord.API.Rest | |||||
| { | |||||
| public class ModifyPresenceParams | |||||
| { | |||||
| public Optional<UserStatus> Status { get; set; } | |||||
| public Optional<Discord.Game> Game { get; set; } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| } | |||||
| @@ -1,6 +1,6 @@ | |||||
| namespace Discord.API.Gateway | |||||
| namespace Discord.API.Voice | |||||
| { | { | ||||
| public enum VoiceOpCodes : byte | |||||
| public enum VoiceOpCode : byte | |||||
| { | { | ||||
| /// <summary> C→S - Used to associate a connection with a token. </summary> | /// <summary> C→S - Used to associate a connection with a token. </summary> | ||||
| Identify = 0, | Identify = 0, | ||||
| @@ -8,8 +8,10 @@ | |||||
| SelectProtocol = 1, | SelectProtocol = 1, | ||||
| /// <summary> C←S - Used to notify that the voice connection was successful and informs the client of available protocols. </summary> | /// <summary> C←S - Used to notify that the voice connection was successful and informs the client of available protocols. </summary> | ||||
| Ready = 2, | Ready = 2, | ||||
| /// <summary> C↔S - Used to keep the connection alive and measure latency. </summary> | |||||
| /// <summary> C→S - Used to keep the connection alive and measure latency. </summary> | |||||
| Heartbeat = 3, | Heartbeat = 3, | ||||
| /// <summary> C←S - Used to reply to a client's heartbeat. </summary> | |||||
| HeartbeatAck = 3, | |||||
| /// <summary> C←S - Used to provide an encryption key to the client. </summary> | /// <summary> C←S - Used to provide an encryption key to the client. </summary> | ||||
| SessionDescription = 4, | SessionDescription = 4, | ||||
| /// <summary> C↔S - Used to inform that a certain user is speaking. </summary> | /// <summary> C↔S - Used to inform that a certain user is speaking. </summary> | ||||
| @@ -9,7 +9,7 @@ namespace Discord.API | |||||
| [JsonProperty("t", NullValueHandling = NullValueHandling.Ignore)] | [JsonProperty("t", NullValueHandling = NullValueHandling.Ignore)] | ||||
| public string Type { get; set; } | public string Type { get; set; } | ||||
| [JsonProperty("s", NullValueHandling = NullValueHandling.Ignore)] | [JsonProperty("s", NullValueHandling = NullValueHandling.Ignore)] | ||||
| public uint? Sequence { get; set; } | |||||
| public int? Sequence { get; set; } | |||||
| [JsonProperty("d")] | [JsonProperty("d")] | ||||
| public object Payload { get; set; } | public object Payload { get; set; } | ||||
| } | } | ||||
| @@ -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<Task> Connected | |||||
| { | |||||
| add { _connectedEvent.Add(value); } | |||||
| remove { _connectedEvent.Remove(value); } | |||||
| } | |||||
| private readonly AsyncEvent<Func<Task>> _connectedEvent = new AsyncEvent<Func<Task>>(); | |||||
| public event Func<Exception, Task> Disconnected | |||||
| { | |||||
| add { _disconnectedEvent.Add(value); } | |||||
| remove { _disconnectedEvent.Remove(value); } | |||||
| } | |||||
| private readonly AsyncEvent<Func<Exception, Task>> _disconnectedEvent = new AsyncEvent<Func<Exception, Task>>(); | |||||
| public event Func<int, int, Task> LatencyUpdated | |||||
| { | |||||
| add { _latencyUpdatedEvent.Add(value); } | |||||
| remove { _latencyUpdatedEvent.Remove(value); } | |||||
| } | |||||
| private readonly AsyncEvent<Func<int, int, Task>> _latencyUpdatedEvent = new AsyncEvent<Func<int, int, Task>>(); | |||||
| private readonly ILogger _audioLogger; | |||||
| #if BENCHMARK | |||||
| private readonly ILogger _benchmarkLogger; | |||||
| #endif | |||||
| internal readonly SemaphoreSlim _connectionLock; | |||||
| private readonly JsonSerializer _serializer; | |||||
| private TaskCompletionSource<bool> _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; | |||||
| /// <summary> Creates a new REST/WebSocket discord client. </summary> | |||||
| 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); | |||||
| }; | |||||
| } | |||||
| /// <inheritdoc /> | |||||
| 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<bool>(); | |||||
| _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; | |||||
| } | |||||
| } | |||||
| /// <inheritdoc /> | |||||
| 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<ReadyEvent>(_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<SessionDescriptionEvent>(_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(); | |||||
| } | |||||
| /// <inheritdoc /> | |||||
| public void Dispose() => Dispose(true); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,13 @@ | |||||
| using System; | |||||
| namespace Discord.Audio | |||||
| { | |||||
| [Flags] | |||||
| public enum AudioMode : byte | |||||
| { | |||||
| Disabled = 0, | |||||
| Outgoing = 1, | |||||
| Incoming = 2, | |||||
| Both = Outgoing | Incoming | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| using System; | |||||
| using System.Threading.Tasks; | |||||
| namespace Discord.Audio | |||||
| { | |||||
| public interface IAudioClient | |||||
| { | |||||
| event Func<Task> Connected; | |||||
| event Func<Exception, Task> Disconnected; | |||||
| event Func<int, int, Task> LatencyUpdated; | |||||
| DiscordVoiceAPIClient ApiClient { get; } | |||||
| /// <summary> Gets the current connection state of this client. </summary> | |||||
| ConnectionState ConnectionState { get; } | |||||
| /// <summary> Gets the estimated round-trip latency, in milliseconds, to the gateway server. </summary> | |||||
| 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); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| namespace Discord.Audio | |||||
| { | |||||
| public enum OpusApplication : int | |||||
| { | |||||
| Voice = 2048, | |||||
| MusicOrMixed = 2049, | |||||
| LowLatency = 2051 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,51 @@ | |||||
| using System; | |||||
| namespace Discord.Audio | |||||
| { | |||||
| internal abstract class OpusConverter : IDisposable | |||||
| { | |||||
| protected IntPtr _ptr; | |||||
| /// <summary> Gets the bit rate of this converter. </summary> | |||||
| public const int BitsPerSample = 16; | |||||
| /// <summary> Gets the bytes per sample. </summary> | |||||
| public const int SampleSize = (BitsPerSample / 8) * MaxChannels; | |||||
| /// <summary> Gets the maximum amount of channels this encoder supports. </summary> | |||||
| public const int MaxChannels = 2; | |||||
| /// <summary> Gets the input sampling rate of this converter. </summary> | |||||
| public int SamplingRate { get; } | |||||
| /// <summary> Gets the number of samples per second for this stream. </summary> | |||||
| 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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| namespace Discord.Audio | |||||
| { | |||||
| internal enum OpusCtl : int | |||||
| { | |||||
| SetBitrateRequest = 4002, | |||||
| GetBitrateRequest = 4003, | |||||
| SetInbandFECRequest = 4012, | |||||
| GetInbandFECRequest = 4013 | |||||
| } | |||||
| } | |||||
| @@ -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}"); | |||||
| } | |||||
| /// <summary> Produces PCM samples from Opus-encoded audio. </summary> | |||||
| /// <param name="input">PCM samples to decode.</param> | |||||
| /// <param name="inputOffset">Offset of the frame in input.</param> | |||||
| /// <param name="output">Buffer to store the decoded frame.</param> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| /// <summary> Gets the coding mode of the encoder. </summary> | |||||
| 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}"); | |||||
| } | |||||
| /// <summary> Produces Opus encoded audio from PCM samples. </summary> | |||||
| /// <param name="input">PCM samples to encode.</param> | |||||
| /// <param name="inputOffset">Offset of the frame in pcmSamples.</param> | |||||
| /// <param name="output">Buffer to store the encoded frame.</param> | |||||
| /// <returns>Length of the frame contained in outputBuffer.</returns> | |||||
| 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; | |||||
| } | |||||
| /// <summary> Gets or sets whether Forward Error Correction is enabled. </summary> | |||||
| public void SetForwardErrorCorrection(bool value) | |||||
| { | |||||
| var result = EncoderCtl(_ptr, OpusCtl.SetInbandFECRequest, value ? 1 : 0); | |||||
| if (result < 0) | |||||
| throw new Exception(((OpusError)result).ToString()); | |||||
| } | |||||
| /// <summary> Gets or sets whether Forward Error Correction is enabled. </summary> | |||||
| 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; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| } | |||||