@@ -3,24 +3,24 @@ Microsoft Visual Studio Solution File, Format Version 12.00 | |||
# Visual Studio 14 | |||
VisualStudioVersion = 14.0.25123.0 | |||
MinimumVisualStudioVersion = 10.0.40219.1 | |||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.Tests", "test\Discord.Net.Tests\Discord.Net.Tests.csproj", "{855D6B1D-847B-42DA-BE6A-23683EA89511}" | |||
EndProject | |||
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Discord.Net", "src\Discord.Net\Discord.Net.xproj", "{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}" | |||
EndProject | |||
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Discord.Net.Commands", "src\Discord.Net.Commands\Discord.Net.Commands.xproj", "{078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}" | |||
EndProject | |||
Global | |||
GlobalSection(SolutionConfigurationPlatforms) = preSolution | |||
Debug|Any CPU = Debug|Any CPU | |||
Release|Any CPU = Release|Any CPU | |||
EndGlobalSection | |||
GlobalSection(ProjectConfigurationPlatforms) = postSolution | |||
{855D6B1D-847B-42DA-BE6A-23683EA89511}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |||
{855D6B1D-847B-42DA-BE6A-23683EA89511}.Debug|Any CPU.Build.0 = Debug|Any CPU | |||
{855D6B1D-847B-42DA-BE6A-23683EA89511}.Release|Any CPU.ActiveCfg = Release|Any CPU | |||
{855D6B1D-847B-42DA-BE6A-23683EA89511}.Release|Any CPU.Build.0 = Release|Any CPU | |||
{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |||
{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Debug|Any CPU.Build.0 = Debug|Any CPU | |||
{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Release|Any CPU.ActiveCfg = Release|Any CPU | |||
{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}.Release|Any CPU.Build.0 = Release|Any CPU | |||
{078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |||
{078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}.Debug|Any CPU.Build.0 = Debug|Any CPU | |||
{078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}.Release|Any CPU.ActiveCfg = Release|Any CPU | |||
{078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}.Release|Any CPU.Build.0 = Release|Any CPU | |||
EndGlobalSection | |||
GlobalSection(SolutionProperties) = preSolution | |||
HideSolutionNode = FALSE | |||
@@ -1,25 +1,21 @@ | |||
# 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) | |||
Discord.Net is an API wrapper for [Discord](http://discordapp.com) written in C#. | |||
Check out the [documentation](https://discordnet.readthedocs.org/en/latest/) or join the [Discord API Chat](https://discord.gg/0SBTUU1wZTVjAMPx). | |||
## Installing | |||
**NuGet is not up to date with 1.0.0-dev.** | |||
You can download Discord.Net and its extensions from NuGet: | |||
### Installation | |||
You can download Discord.Net and its command extension from NuGet: | |||
- [Discord.Net](https://www.nuget.org/packages/Discord.Net/) | |||
- [Discord.Net.Commands](https://www.nuget.org/packages/Discord.Net.Commands/) | |||
- [Discord.Net.Modules](https://www.nuget.org/packages/Discord.Net.Modules/) | |||
- [Discord.Net.Audio](https://www.nuget.org/packages/Discord.Net.Audio/) | |||
### Compiling | |||
In order to compile Discord.Net, you require at least the following: | |||
- [Visual Studio 2015](https://www.visualstudio.com/downloads/download-visual-studio-vs) | |||
- [Visual Studio 2015 Update 2](https://www.visualstudio.com/en-us/news/vs2015-update2-vs.aspx) | |||
- [Visual Studio .Net Core Plugin](https://www.microsoft.com/net/core#windows) | |||
- NuGet 3.3+ (available through Visual Studio) | |||
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")] | |||
public string ProxyUrl { get; set; } | |||
[JsonProperty("height")] | |||
public int? Height { get; set; } | |||
public Optional<int> Height { get; set; } | |||
[JsonProperty("width")] | |||
public int? Width { get; set; } | |||
public Optional<int> Width { get; set; } | |||
} | |||
} |
@@ -14,28 +14,28 @@ namespace Discord.API | |||
//GuildChannel | |||
[JsonProperty("guild_id")] | |||
public ulong? GuildId { get; set; } | |||
public Optional<ulong> GuildId { get; set; } | |||
[JsonProperty("name")] | |||
public string Name { get; set; } | |||
public Optional<string> Name { get; set; } | |||
[JsonProperty("type")] | |||
public ChannelType Type { get; set; } | |||
public Optional<ChannelType> Type { get; set; } | |||
[JsonProperty("position")] | |||
public int Position { get; set; } | |||
public Optional<int> Position { get; set; } | |||
[JsonProperty("permission_overwrites")] | |||
public Overwrite[] PermissionOverwrites { get; set; } | |||
public Optional<Overwrite[]> PermissionOverwrites { get; set; } | |||
//TextChannel | |||
[JsonProperty("topic")] | |||
public string Topic { get; set; } | |||
public Optional<string> Topic { get; set; } | |||
//VoiceChannel | |||
[JsonProperty("bitrate")] | |||
public int Bitrate { get; set; } | |||
public Optional<int> Bitrate { get; set; } | |||
[JsonProperty("user_limit")] | |||
public int UserLimit { get; set; } | |||
public Optional<int> UserLimit { get; set; } | |||
//DMChannel | |||
[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; } | |||
[JsonProperty("integrations")] | |||
public IEnumerable<ulong> Integrations { get; set; } | |||
public IReadOnlyCollection<ulong> Integrations { get; set; } | |||
} | |||
} |
@@ -13,8 +13,8 @@ namespace Discord.API | |||
[JsonProperty("url")] | |||
public string Url { get; set; } | |||
[JsonProperty("thumbnail")] | |||
public EmbedThumbnail Thumbnail { get; set; } | |||
public Optional<EmbedThumbnail> Thumbnail { get; set; } | |||
[JsonProperty("provider")] | |||
public EmbedProvider Provider { get; set; } | |||
public Optional<EmbedProvider> Provider { get; set; } | |||
} | |||
} |
@@ -9,8 +9,8 @@ namespace Discord.API | |||
[JsonProperty("proxy_url")] | |||
public string ProxyUrl { get; set; } | |||
[JsonProperty("height")] | |||
public int? Height { get; set; } | |||
public Optional<int> Height { get; set; } | |||
[JsonProperty("width")] | |||
public int? Width { get; set; } | |||
public Optional<int> Width { get; set; } | |||
} | |||
} |
@@ -7,8 +7,8 @@ namespace Discord.API | |||
[JsonProperty("name")] | |||
public string Name { get; set; } | |||
[JsonProperty("url")] | |||
public string StreamUrl { get; set; } | |||
public Optional<string> StreamUrl { get; set; } | |||
[JsonProperty("type")] | |||
public StreamType StreamType { get; set; } | |||
public Optional<StreamType?> StreamType { get; set; } | |||
} | |||
} |
@@ -25,7 +25,7 @@ namespace Discord.API | |||
[JsonProperty("embed_channel_id")] | |||
public ulong? EmbedChannelId { get; set; } | |||
[JsonProperty("verification_level")] | |||
public int VerificationLevel { get; set; } | |||
public VerificationLevel VerificationLevel { get; set; } | |||
[JsonProperty("voice_states")] | |||
public VoiceState[] VoiceStates { get; set; } | |||
[JsonProperty("roles")] | |||
@@ -34,5 +34,9 @@ namespace Discord.API | |||
public Emoji[] Emojis { get; set; } | |||
[JsonProperty("features")] | |||
public string[] Features { get; set; } | |||
[JsonProperty("mfa_level")] | |||
public MfaLevel MfaLevel { get; set; } | |||
[JsonProperty("default_message_notifications")] | |||
public DefaultMessageNotifications DefaultMessageNotifications { get; set; } | |||
} | |||
} |
@@ -7,6 +7,6 @@ namespace Discord.API | |||
[JsonProperty("enabled")] | |||
public bool Enabled { get; set; } | |||
[JsonProperty("channel_id")] | |||
public ulong? ChannelId { get; set; } | |||
public ulong ChannelId { get; set; } | |||
} | |||
} |
@@ -8,11 +8,11 @@ namespace Discord.API | |||
[JsonProperty("user")] | |||
public User User { get; set; } | |||
[JsonProperty("nick")] | |||
public string Nick { get; set; } | |||
public Optional<string> Nick { get; set; } | |||
[JsonProperty("roles")] | |||
public ulong[] Roles { get; set; } | |||
[JsonProperty("joined_at")] | |||
public DateTime?JoinedAt { get; set; } | |||
public DateTimeOffset JoinedAt { get; set; } | |||
[JsonProperty("deaf")] | |||
public bool Deaf { get; set; } | |||
[JsonProperty("mute")] | |||
@@ -26,6 +26,6 @@ namespace Discord.API | |||
[JsonProperty("account")] | |||
public IntegrationAccount Account { get; set; } | |||
[JsonProperty("synced_at")] | |||
public DateTime SyncedAt { get; set; } | |||
public DateTimeOffset SyncedAt { get; set; } | |||
} | |||
} |
@@ -16,7 +16,7 @@ namespace Discord.API | |||
[JsonProperty("temporary")] | |||
public bool Temporary { get; set; } | |||
[JsonProperty("created_at")] | |||
public DateTime CreatedAt { get; set; } | |||
public DateTimeOffset CreatedAt { get; set; } | |||
[JsonProperty("revoked")] | |||
public bool Revoked { get; set; } | |||
} | |||
@@ -10,24 +10,24 @@ namespace Discord.API | |||
[JsonProperty("channel_id")] | |||
public ulong ChannelId { get; set; } | |||
[JsonProperty("author")] | |||
public User Author { get; set; } | |||
public Optional<User> Author { get; set; } | |||
[JsonProperty("content")] | |||
public string Content { get; set; } | |||
public Optional<string> Content { get; set; } | |||
[JsonProperty("timestamp")] | |||
public DateTime Timestamp { get; set; } | |||
public Optional<DateTimeOffset> Timestamp { get; set; } | |||
[JsonProperty("edited_timestamp")] | |||
public DateTime? EditedTimestamp { get; set; } | |||
public Optional<DateTimeOffset?> EditedTimestamp { get; set; } | |||
[JsonProperty("tts")] | |||
public bool IsTextToSpeech { get; set; } | |||
public Optional<bool> IsTextToSpeech { get; set; } | |||
[JsonProperty("mention_everyone")] | |||
public bool IsMentioningEveryone { get; set; } | |||
public Optional<bool> MentionEveryone { get; set; } | |||
[JsonProperty("mentions")] | |||
public User[] Mentions { get; set; } | |||
public Optional<User[]> Mentions { get; set; } | |||
[JsonProperty("attachments")] | |||
public Attachment[] Attachments { get; set; } | |||
public Optional<Attachment[]> Attachments { get; set; } | |||
[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")] | |||
public int MentionCount { get; set; } | |||
[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")] | |||
public string Name { get; set; } | |||
[JsonProperty("color")] | |||
public uint? Color { get; set; } | |||
public uint Color { get; set; } | |||
[JsonProperty("hoist")] | |||
public bool? Hoist { get; set; } | |||
public bool Hoist { get; set; } | |||
[JsonProperty("position")] | |||
public int? Position { get; set; } | |||
public int Position { get; set; } | |||
[JsonProperty("permissions"), Int53] | |||
public ulong? Permissions { get; set; } | |||
public ulong Permissions { get; set; } | |||
[JsonProperty("managed")] | |||
public bool? Managed { get; set; } | |||
public bool Managed { get; set; } | |||
} | |||
} |
@@ -9,14 +9,18 @@ namespace Discord.API | |||
[JsonProperty("username")] | |||
public string Username { get; set; } | |||
[JsonProperty("discriminator")] | |||
public ushort Discriminator { get; set; } | |||
public string Discriminator { get; set; } | |||
[JsonProperty("bot")] | |||
public bool Bot { get; set; } | |||
[JsonProperty("avatar")] | |||
public string Avatar { get; set; } | |||
//CurrentUser | |||
[JsonProperty("verified")] | |||
public bool IsVerified { get; set; } | |||
public bool Verified { get; set; } | |||
[JsonProperty("email")] | |||
public string Email { get; set; } | |||
[JsonProperty("bot")] | |||
public bool Bot { get; set; } | |||
[JsonProperty("mfa_enabled")] | |||
public bool MfaEnabled { get; set; } | |||
} | |||
} |
@@ -7,7 +7,7 @@ namespace Discord.API | |||
[JsonProperty("guild_id")] | |||
public ulong? GuildId { get; set; } | |||
[JsonProperty("channel_id")] | |||
public ulong ChannelId { get; set; } | |||
public ulong? ChannelId { get; set; } | |||
[JsonProperty("user_id")] | |||
public ulong UserId { get; set; } | |||
[JsonProperty("session_id")] | |||
@@ -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 | |||
{ | |||
public enum GatewayOpCodes : byte | |||
public enum GatewayOpCode : byte | |||
{ | |||
/// <summary> C←S - Used to send most events. </summary> | |||
Dispatch = 0, | |||
@@ -12,13 +12,21 @@ | |||
StatusUpdate = 3, | |||
/// <summary> C→S - Used to join a particular voice channel. </summary> | |||
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, | |||
/// <summary> C→S - Used to resume a connection after a redirect occurs. </summary> | |||
Resume = 6, | |||
/// <summary> C←S - Used to notify a client that they must reconnect to another gateway. </summary> | |||
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")] | |||
public ulong GuildId { get; set; } | |||
[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")] | |||
public ulong GuildId { get; set; } | |||
[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")] | |||
public ReadState[] ReadStates { get; set; } | |||
[JsonProperty("guilds")] | |||
public Guild[] Guilds { get; set; } | |||
public ExtendedGuild[] Guilds { get; set; } | |||
[JsonProperty("private_channels")] | |||
public Channel[] PrivateChannels { get; set; } | |||
[JsonProperty("heartbeat_interval")] | |||
public int HeartbeatInterval { get; set; } | |||
[JsonProperty("relationships")] | |||
public Relationship[] Relationships { get; set; } | |||
//Ignored | |||
[JsonProperty("user_settings")] | |||
public object UserSettings { get; set; } | |||
/*[JsonProperty("user_settings")] | |||
[JsonProperty("user_guild_settings")] | |||
public object UserGuildSettings { get; set; } | |||
[JsonProperty("tutorial")] | |||
public object Tutorial { get; set; } | |||
[JsonProperty("tutorial")]*/ | |||
} | |||
} |
@@ -1,14 +1,19 @@ | |||
using Newtonsoft.Json; | |||
using System.Collections.Generic; | |||
using System.Linq; | |||
namespace Discord.API.Gateway | |||
{ | |||
public class RequestMembersParams | |||
{ | |||
[JsonProperty("guild_id")] | |||
public ulong[] GuildId { get; set; } | |||
[JsonProperty("query")] | |||
public string Query { get; set; } | |||
[JsonProperty("limit")] | |||
public int Limit { get; set; } | |||
[JsonProperty("guild_id")] | |||
public IEnumerable<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")] | |||
public string SessionId { get; set; } | |||
[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")] | |||
public ulong RecipientId { get; set; } | |||
[JsonIgnore] | |||
public IUser Recipient { set { RecipientId = value.Id; } } | |||
} | |||
} |
@@ -1,11 +1,14 @@ | |||
using Newtonsoft.Json; | |||
using System.Collections.Generic; | |||
using System.Linq; | |||
namespace Discord.API.Rest | |||
{ | |||
public class DeleteMessagesParam | |||
public class DeleteMessagesParams | |||
{ | |||
[JsonProperty("messages")] | |||
public IEnumerable<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 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; | |||
namespace Discord.API.Rest | |||
@@ -8,12 +7,6 @@ namespace Discord.API.Rest | |||
{ | |||
[JsonProperty("username")] | |||
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] | |||
public Optional<Stream> Avatar { get; set; } | |||
} | |||
@@ -1,5 +1,4 @@ | |||
using Discord.Net.Converters; | |||
using Newtonsoft.Json; | |||
using Newtonsoft.Json; | |||
namespace Discord.API.Rest | |||
{ | |||
@@ -7,7 +6,10 @@ namespace Discord.API.Rest | |||
{ | |||
[JsonProperty("enabled")] | |||
public Optional<bool> Enabled { get; set; } | |||
[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 System.Collections.Generic; | |||
using System.Linq; | |||
namespace Discord.API.Rest | |||
{ | |||
public class ModifyGuildMemberParams | |||
{ | |||
[JsonProperty("roles")] | |||
public Optional<ulong[]> Roles { get; set; } | |||
[JsonProperty("mute")] | |||
public Optional<bool> Mute { get; set; } | |||
[JsonProperty("deaf")] | |||
public Optional<bool> Deaf { get; set; } | |||
[JsonProperty("nick")] | |||
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")] | |||
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; | |||
namespace Discord.API.Rest | |||
@@ -11,16 +10,24 @@ namespace Discord.API.Rest | |||
[JsonProperty("region")] | |||
public Optional<IVoiceRegion> Region { get; set; } | |||
[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")] | |||
public Optional<int> AFKTimeout { get; set; } | |||
[JsonProperty("icon"), Image] | |||
public Optional<Stream> Icon { get; set; } | |||
[JsonProperty("owner_id")] | |||
public Optional<GuildMember> Owner { get; set; } | |||
[JsonProperty("splash"), Image] | |||
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> | |||
Identify = 0, | |||
@@ -8,8 +8,10 @@ | |||
SelectProtocol = 1, | |||
/// <summary> C←S - Used to notify that the voice connection was successful and informs the client of available protocols. </summary> | |||
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, | |||
/// <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> | |||
SessionDescription = 4, | |||
/// <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)] | |||
public string Type { get; set; } | |||
[JsonProperty("s", NullValueHandling = NullValueHandling.Ignore)] | |||
public uint? Sequence { get; set; } | |||
public int? Sequence { get; set; } | |||
[JsonProperty("d")] | |||
public object Payload { get; set; } | |||
} | |||
@@ -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); | |||
} | |||
} | |||
} |