@@ -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); | |||||
} | |||||
} | |||||
} |