diff --git a/src/Discord.Net/API/DiscordAPIClient.cs b/src/Discord.Net/API/DiscordAPIClient.cs index 870950142..bcb7691c4 100644 --- a/src/Discord.Net/API/DiscordAPIClient.cs +++ b/src/Discord.Net/API/DiscordAPIClient.cs @@ -106,7 +106,7 @@ namespace Discord.API } } public void Dispose() => Dispose(true); - + public async Task LoginAsync(TokenType tokenType, string token, RequestOptions options = null) { await _connectionLock.WaitAsync().ConfigureAwait(false); @@ -121,7 +121,7 @@ namespace Discord.API if (LoginState != LoginState.LoggedOut) await LogoutInternalAsync().ConfigureAwait(false); LoginState = LoginState.LoggingIn; - + try { _loginCancelToken = new CancellationTokenSource(); @@ -172,7 +172,7 @@ namespace Discord.API //An exception here will lock the client into the unusable LoggingOut state, but that's probably fine since our client is in an undefined state too. if (LoginState == LoginState.LoggedOut) return; LoginState = LoginState.LoggingOut; - + try { _loginCancelToken?.Cancel(false); } catch { } @@ -250,7 +250,7 @@ namespace Discord.API if (ConnectionState == ConnectionState.Disconnected) return; ConnectionState = ConnectionState.Disconnecting; - + try { _connectCancelToken?.Cancel(false); } catch { } @@ -260,29 +260,29 @@ namespace Discord.API } //REST - public Task SendAsync(string method, string endpoint, + public Task SendAsync(string method, string endpoint, GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) => SendInternalAsync(method, endpoint, null, true, BucketGroup.Global, (int)bucket, 0, options); - public Task SendAsync(string method, string endpoint, object payload, + public Task SendAsync(string method, string endpoint, object payload, GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) => SendInternalAsync(method, endpoint, payload, true, BucketGroup.Global, (int)bucket, 0, options); public async Task SendAsync(string method, string endpoint, GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) where TResponse : class => DeserializeJson(await SendInternalAsync(method, endpoint, null, false, BucketGroup.Global, (int)bucket, 0, options).ConfigureAwait(false)); - public async Task SendAsync(string method, string endpoint, object payload, GlobalBucket bucket = + public async Task SendAsync(string method, string endpoint, object payload, GlobalBucket bucket = GlobalBucket.GeneralRest, RequestOptions options = null) where TResponse : class => DeserializeJson(await SendInternalAsync(method, endpoint, payload, false, BucketGroup.Global, (int)bucket, 0, options).ConfigureAwait(false)); - - public Task SendAsync(string method, string endpoint, + + public Task SendAsync(string method, string endpoint, GuildBucket bucket, ulong guildId, RequestOptions options = null) => SendInternalAsync(method, endpoint, null, true, BucketGroup.Guild, (int)bucket, guildId, options); - public Task SendAsync(string method, string endpoint, object payload, + public Task SendAsync(string method, string endpoint, object payload, GuildBucket bucket, ulong guildId, RequestOptions options = null) => SendInternalAsync(method, endpoint, payload, true, BucketGroup.Guild, (int)bucket, guildId, options); - public async Task SendAsync(string method, string endpoint, + public async Task SendAsync(string method, string endpoint, GuildBucket bucket, ulong guildId, RequestOptions options = null) where TResponse : class => DeserializeJson(await SendInternalAsync(method, endpoint, null, false, BucketGroup.Guild, (int)bucket, guildId, options).ConfigureAwait(false)); - public async Task SendAsync(string method, string endpoint, object payload, + public async Task SendAsync(string method, string endpoint, object payload, GuildBucket bucket, ulong guildId, RequestOptions options = null) where TResponse : class => DeserializeJson(await SendInternalAsync(method, endpoint, payload, false, BucketGroup.Guild, (int)bucket, guildId, options).ConfigureAwait(false)); @@ -311,7 +311,7 @@ namespace Discord.API => SendGatewayInternalAsync(opCode, payload, BucketGroup.Guild, (int)bucket, guildId, options); //Core - private async Task SendInternalAsync(string method, string endpoint, object payload, bool headerOnly, + private async Task SendInternalAsync(string method, string endpoint, object payload, bool headerOnly, BucketGroup group, int bucketId, ulong guildId, RequestOptions options = null) { var stopwatch = Stopwatch.StartNew(); @@ -326,7 +326,7 @@ namespace Discord.API return responseStream; } - private async Task SendMultipartInternalAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, + private async Task SendMultipartInternalAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, BucketGroup group, int bucketId, ulong guildId, RequestOptions options = null) { var stopwatch = Stopwatch.StartNew(); @@ -339,7 +339,7 @@ namespace Discord.API return responseStream; } - private async Task SendGatewayInternalAsync(GatewayOpCode opCode, object payload, + private async Task SendGatewayInternalAsync(GatewayOpCode opCode, object payload, BucketGroup group, int bucketId, ulong guildId, RequestOptions options) { //TODO: Add ETF @@ -913,7 +913,7 @@ namespace Discord.API relativeDir = "around"; break; } - + int runs = (limit + DiscordRestConfig.MaxMessagesPerBatch - 1) / DiscordRestConfig.MaxMessagesPerBatch; int lastRunCount = limit - (runs - 1) * DiscordRestConfig.MaxMessagesPerBatch; var result = new API.Message[runs][]; @@ -1027,7 +1027,7 @@ namespace Discord.API { Preconditions.NotNull(args, nameof(args)); Preconditions.NotEqual(channelId, 0, nameof(channelId)); - + if (args._content.GetValueOrDefault(null) == null) args._content = ""; else if (args._content.IsSpecified) @@ -1153,7 +1153,7 @@ namespace Discord.API { Preconditions.NotNullOrEmpty(username, nameof(username)); Preconditions.NotNullOrEmpty(discriminator, nameof(discriminator)); - + try { var models = await QueryUsersAsync($"{username}#{discriminator}", 1, options: options).ConfigureAwait(false); diff --git a/src/Discord.Net/API/DiscordRpcAPIClient.cs b/src/Discord.Net/API/DiscordRpcAPIClient.cs new file mode 100644 index 000000000..328d424db --- /dev/null +++ b/src/Discord.Net/API/DiscordRpcAPIClient.cs @@ -0,0 +1,288 @@ +using Discord.API.Gateway; +using Discord.API.Rest; +using Discord.API.Rpc; +using Discord.Net; +using Discord.Net.Converters; +using Discord.Net.Queue; +using Discord.Net.Rest; +using Discord.Net.WebSockets; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.API +{ + public class DiscordRpcApiClient : IDisposable + { + private object _eventLock = new object(); + + public event Func SentRpcMessage { add { _sentRpcMessageEvent.Add(value); } remove { _sentRpcMessageEvent.Remove(value); } } + private readonly AsyncEvent> _sentRpcMessageEvent = new AsyncEvent>(); + + public event Func ReceivedRpcEvent { add { _receivedRpcEvent.Add(value); } remove { _receivedRpcEvent.Remove(value); } } + private readonly AsyncEvent> _receivedRpcEvent = new AsyncEvent>(); + public event Func Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + + private readonly RequestQueue _requestQueue; + private readonly JsonSerializer _serializer; + private readonly IWebSocketClient _webSocketClient; + private readonly SemaphoreSlim _connectionLock; + private readonly string _clientId; + private CancellationTokenSource _loginCancelToken, _connectCancelToken; + private string _authToken; + private bool _isDisposed; + + public LoginState LoginState { get; private set; } + public ConnectionState ConnectionState { get; private set; } + + public DiscordRpcApiClient(string clientId, WebSocketProvider webSocketProvider, JsonSerializer serializer = null, RequestQueue requestQueue = null) + { + _connectionLock = new SemaphoreSlim(1, 1); + _clientId = clientId; + + _requestQueue = requestQueue ?? new RequestQueue(); + + if (webSocketProvider != null) + { + _webSocketClient = webSocketProvider(); + //_gatewayClient.SetHeader("user-agent", DiscordConfig.UserAgent); (Causes issues in .Net 4.6+) + _webSocketClient.BinaryMessage += async (data, index, count) => + { + using (var compressed = new MemoryStream(data, index + 2, count - 2)) + using (var decompressed = new MemoryStream()) + { + using (var zlib = new DeflateStream(compressed, CompressionMode.Decompress)) + zlib.CopyTo(decompressed); + decompressed.Position = 0; + using (var reader = new StreamReader(decompressed)) + { + var msg = JsonConvert.DeserializeObject(reader.ReadToEnd()); + await _receivedRpcEvent.InvokeAsync(msg.Cmd, msg.Event, msg.Data, msg.Nonce).ConfigureAwait(false); + } + } + }; + _webSocketClient.TextMessage += async text => + { + var msg = JsonConvert.DeserializeObject(text); + await _receivedRpcEvent.InvokeAsync(msg.Cmd, msg.Event, msg.Data, msg.Nonce).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 LoginAsync(TokenType tokenType, string token, RequestOptions options = null) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await LoginInternalAsync(tokenType, token, options).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task LoginInternalAsync(TokenType tokenType, string token, RequestOptions options = null) + { + if (LoginState != LoginState.LoggedOut) + await LogoutInternalAsync().ConfigureAwait(false); + + if (tokenType != TokenType.Bearer) + throw new InvalidOperationException("RPC only supports bearer tokens"); + + LoginState = LoginState.LoggingIn; + try + { + _loginCancelToken = new CancellationTokenSource(); + await _requestQueue.SetCancelTokenAsync(_loginCancelToken.Token).ConfigureAwait(false); + + _authToken = token; + + LoginState = LoginState.LoggedIn; + } + catch (Exception) + { + await LogoutInternalAsync().ConfigureAwait(false); + throw; + } + } + + public async Task LogoutAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await LogoutInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task LogoutInternalAsync() + { + //An exception here will lock the client into the unusable LoggingOut state, but that's probably fine since our client is in an undefined state too. + if (LoginState == LoginState.LoggedOut) return; + LoginState = LoginState.LoggingOut; + + try { _loginCancelToken?.Cancel(false); } + catch { } + + await DisconnectInternalAsync().ConfigureAwait(false); + await _requestQueue.ClearAsync().ConfigureAwait(false); + + await _requestQueue.SetCancelTokenAsync(CancellationToken.None).ConfigureAwait(false); + + LoginState = LoginState.LoggedOut; + } + + public async Task ConnectAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task ConnectInternalAsync() + { + /*if (LoginState != LoginState.LoggedIn) + throw new InvalidOperationException("You must log in before connecting.");*/ + + ConnectionState = ConnectionState.Connecting; + try + { + _connectCancelToken = new CancellationTokenSource(); + if (_webSocketClient != null) + _webSocketClient.SetCancelToken(_connectCancelToken.Token); + + bool success = false; + for (int port = DiscordRpcConfig.PortRangeStart; port <= DiscordRpcConfig.PortRangeEnd; port++) + { + try + { + string url = $"wss://discordapp.io:{port}/?v={DiscordRpcConfig.RpcAPIVersion}&client_id={_clientId}"; + await _webSocketClient.ConnectAsync(url).ConfigureAwait(false); + success = true; + break; + } + catch (Exception) + { + } + } + if (!success) + throw new Exception("Unable to connect to the RPC server."); + + 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 (_webSocketClient == null) + throw new NotSupportedException("This client is not configured with websocket support."); + + if (ConnectionState == ConnectionState.Disconnected) return; + ConnectionState = ConnectionState.Disconnecting; + + try { _connectCancelToken?.Cancel(false); } + catch { } + + await _webSocketClient.DisconnectAsync().ConfigureAwait(false); + + ConnectionState = ConnectionState.Disconnected; + } + + //Core + public Task SendRpcAsync(string cmd, object payload, GlobalBucket bucket = GlobalBucket.GeneralRpc, RequestOptions options = null) + => SendRpcAsyncInternal(cmd, payload, BucketGroup.Global, (int)bucket, 0, options); + public Task SendRpcAsync(string cmd, object payload, GuildBucket bucket, ulong guildId, RequestOptions options = null) + => SendRpcAsyncInternal(cmd, payload, BucketGroup.Guild, (int)bucket, guildId, options); + private async Task SendRpcAsyncInternal(string cmd, object payload, + BucketGroup group, int bucketId, ulong guildId, RequestOptions options) + { + //TODO: Add Nonce to pair sent requests with responses + byte[] bytes = null; + payload = new RpcMessage { Cmd = cmd, Args = payload, Nonce = Guid.NewGuid().ToString() }; + if (payload != null) + bytes = Encoding.UTF8.GetBytes(SerializeJson(payload)); + await _requestQueue.SendAsync(new WebSocketRequest(_webSocketClient, bytes, true, options), group, bucketId, guildId).ConfigureAwait(false); + await _sentRpcMessageEvent.InvokeAsync(cmd).ConfigureAwait(false); + } + + //Rpc + public async Task SendAuthenticateAsync(RequestOptions options = null) + { + var msg = new AuthenticateParams() + { + AccessToken = _authToken + }; + await SendRpcAsync("AUTHENTICATE", msg, options: options).ConfigureAwait(false); + } + public async Task SendAuthorizeAsync(string[] scopes, RequestOptions options = null) + { + var msg = new AuthorizeParams() + { + ClientId = _clientId, + Scopes = scopes + }; + await SendRpcAsync("AUTHORIZE", msg, options: options).ConfigureAwait(false); + } + + //Helpers + private static double ToMilliseconds(Stopwatch stopwatch) => Math.Round((double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2); + private string SerializeJson(object value) + { + var sb = new StringBuilder(256); + using (TextWriter text = new StringWriter(sb, CultureInfo.InvariantCulture)) + using (JsonWriter writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, value); + return sb.ToString(); + } + private T DeserializeJson(Stream jsonStream) + { + using (TextReader text = new StreamReader(jsonStream)) + using (JsonReader reader = new JsonTextReader(text)) + return _serializer.Deserialize(reader); + } + } +} diff --git a/src/Discord.Net/API/Rpc/Application.cs b/src/Discord.Net/API/Rpc/Application.cs new file mode 100644 index 000000000..1a5520e69 --- /dev/null +++ b/src/Discord.Net/API/Rpc/Application.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rpc +{ + public class Application + { + [JsonProperty("description")] + public string Description { get; set; } + [JsonProperty("icon")] + public string Icon { get; set; } + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("rpc_origins")] + public string RpcOrigins { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/AuthenticateEvent.cs b/src/Discord.Net/API/Rpc/AuthenticateEvent.cs new file mode 100644 index 000000000..ca99ce8ff --- /dev/null +++ b/src/Discord.Net/API/Rpc/AuthenticateEvent.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Rpc +{ + public class AuthenticateEvent + { + [JsonProperty("application")] + public Application Application { get; set; } + [JsonProperty("expires")] + public DateTimeOffset Expires { get; set; } + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("scopes")] + public string[] Scopes { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/AuthenticateParams.cs b/src/Discord.Net/API/Rpc/AuthenticateParams.cs new file mode 100644 index 000000000..f64fff8b9 --- /dev/null +++ b/src/Discord.Net/API/Rpc/AuthenticateParams.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rpc +{ + public class AuthenticateParams + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/AuthorizeEvent.cs b/src/Discord.Net/API/Rpc/AuthorizeEvent.cs new file mode 100644 index 000000000..8416d5f86 --- /dev/null +++ b/src/Discord.Net/API/Rpc/AuthorizeEvent.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Rpc +{ + public class AuthorizeEvent + { + [JsonProperty("code")] + public string Code { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/AuthorizeParams.cs b/src/Discord.Net/API/Rpc/AuthorizeParams.cs new file mode 100644 index 000000000..bf8bd162a --- /dev/null +++ b/src/Discord.Net/API/Rpc/AuthorizeParams.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rpc +{ + public class AuthorizeParams + { + [JsonProperty("client_id")] + public string ClientId { get; set; } + [JsonProperty("scopes")] + public string[] Scopes { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/ErrorEvent.cs b/src/Discord.Net/API/Rpc/ErrorEvent.cs new file mode 100644 index 000000000..99eee4f4a --- /dev/null +++ b/src/Discord.Net/API/Rpc/ErrorEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rpc +{ + public class ErrorEvent + { + [JsonProperty("code")] + public int Code { get; set; } + [JsonProperty("message")] + public string Message { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/GuildStatusEvent.cs b/src/Discord.Net/API/Rpc/GuildStatusEvent.cs new file mode 100644 index 000000000..5990dace4 --- /dev/null +++ b/src/Discord.Net/API/Rpc/GuildStatusEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rpc +{ + public class GuildStatusEvent + { + [JsonProperty("guild")] + public Guild Guild { get; set; } + [JsonProperty("online")] + public int Online { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/ReadyEvent.cs b/src/Discord.Net/API/Rpc/ReadyEvent.cs new file mode 100644 index 000000000..3bd48c12e --- /dev/null +++ b/src/Discord.Net/API/Rpc/ReadyEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rpc +{ + public class ReadyEvent + { + [JsonProperty("v")] + public int Version { get; set; } + [JsonProperty("config")] + public RpcConfig Config { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/RpcConfig.cs b/src/Discord.Net/API/Rpc/RpcConfig.cs new file mode 100644 index 000000000..07b703367 --- /dev/null +++ b/src/Discord.Net/API/Rpc/RpcConfig.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rpc +{ + public class RpcConfig + { + [JsonProperty("cdn_host")] + public string CdnHost { get; set; } + [JsonProperty("api_endpoint")] + public string ApiEndpoint { get; set; } + [JsonProperty("environment")] + public string Environment { get; set; } + } +} diff --git a/src/Discord.Net/API/Rpc/RpcMessage.cs b/src/Discord.Net/API/Rpc/RpcMessage.cs new file mode 100644 index 000000000..9226e0fa8 --- /dev/null +++ b/src/Discord.Net/API/Rpc/RpcMessage.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rpc +{ + public class RpcMessage + { + [JsonProperty("cmd")] + public string Cmd { get; set; } + [JsonProperty("nonce")] + public string Nonce { get; set; } + [JsonProperty("evt")] + public string Event { get; set; } + [JsonProperty("data")] + public object Data { get; set; } + [JsonProperty("args")] + public object Args { get; set; } + } +} diff --git a/src/Discord.Net/DiscordRestClient.cs b/src/Discord.Net/DiscordRestClient.cs index 829753b64..676ba9200 100644 --- a/src/Discord.Net/DiscordRestClient.cs +++ b/src/Discord.Net/DiscordRestClient.cs @@ -28,9 +28,9 @@ namespace Discord internal readonly ILogger _clientLogger, _restLogger, _queueLogger; internal readonly SemaphoreSlim _connectionLock; internal readonly RequestQueue _requestQueue; - internal bool _isDisposed; internal SelfUser _currentUser; private bool _isFirstLogSub; + internal bool _isDisposed; public API.DiscordApiClient ApiClient { get; } internal LogManager LogManager { get; } @@ -303,8 +303,10 @@ namespace Discord internal virtual void Dispose(bool disposing) { if (!_isDisposed) + { + ApiClient.Dispose(); _isDisposed = true; - ApiClient.Dispose(); + } } /// public void Dispose() => Dispose(true); @@ -312,10 +314,11 @@ namespace Discord private async Task WriteInitialLog() { if (this is DiscordSocketClient) - await _clientLogger.InfoAsync($"DiscordSocketClient v{DiscordConfig.Version} (API v{DiscordConfig.APIVersion}, {DiscordSocketConfig.GatewayEncoding})").ConfigureAwait(false); + await _clientLogger.InfoAsync($"DiscordSocketClient v{DiscordConfig.Version} (API v{DiscordConfig.APIVersion}, {DiscordConfig.GatewayEncoding})").ConfigureAwait(false); + else if (this is DiscordRpcClient) + await _clientLogger.InfoAsync($"DiscordRpcClient v{DiscordConfig.Version} (API v{DiscordConfig.APIVersion}, RPC API v{DiscordRpcConfig.RpcAPIVersion})").ConfigureAwait(false); else - await _clientLogger.InfoAsync($"DiscordRestClient v{DiscordConfig.Version} (API v{DiscordConfig.APIVersion})").ConfigureAwait(false); - + await _clientLogger.InfoAsync($"DiscordClient v{DiscordConfig.Version} (API v{DiscordConfig.APIVersion})").ConfigureAwait(false); await _clientLogger.VerboseAsync($"Runtime: {RuntimeInformation.FrameworkDescription.Trim()} ({ToArchString(RuntimeInformation.ProcessArchitecture)})").ConfigureAwait(false); await _clientLogger.VerboseAsync($"OS: {RuntimeInformation.OSDescription.Trim()} ({ToArchString(RuntimeInformation.OSArchitecture)})").ConfigureAwait(false); await _clientLogger.VerboseAsync($"Processors: {Environment.ProcessorCount}").ConfigureAwait(false); diff --git a/src/Discord.Net/DiscordRpcClient.cs b/src/Discord.Net/DiscordRpcClient.cs new file mode 100644 index 000000000..cfaaed6c4 --- /dev/null +++ b/src/Discord.Net/DiscordRpcClient.cs @@ -0,0 +1,379 @@ +using Discord.API.Rpc; +using Discord.Logging; +using Discord.Net.Converters; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord +{ + public class DiscordRpcClient + { + public event Func Log { add { _logEvent.Add(value); } remove { _logEvent.Remove(value); } } + private readonly AsyncEvent> _logEvent = new AsyncEvent>(); + + public event Func LoggedIn { add { _loggedInEvent.Add(value); } remove { _loggedInEvent.Remove(value); } } + private readonly AsyncEvent> _loggedInEvent = new AsyncEvent>(); + public event Func LoggedOut { add { _loggedOutEvent.Add(value); } remove { _loggedOutEvent.Remove(value); } } + private readonly AsyncEvent> _loggedOutEvent = new AsyncEvent>(); + + public event Func Connected { add { _connectedEvent.Add(value); } remove { _connectedEvent.Remove(value); } } + private readonly AsyncEvent> _connectedEvent = new AsyncEvent>(); + public event Func Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + + public event Func Ready { add { _readyEvent.Add(value); } remove { _readyEvent.Remove(value); } } + private readonly AsyncEvent> _readyEvent = new AsyncEvent>(); + + private readonly ILogger _clientLogger, _rpcLogger; + private readonly SemaphoreSlim _connectionLock; + private readonly JsonSerializer _serializer; + + private TaskCompletionSource _connectTask; + private CancellationTokenSource _cancelToken; + internal SelfUser _currentUser; + private Task _reconnectTask; + private bool _isFirstLogSub; + private bool _isReconnecting; + private bool _isDisposed; + private string[] _scopes; + + public API.DiscordRpcApiClient ApiClient { get; } + internal LogManager LogManager { get; } + public LoginState LoginState { get; private set; } + public ConnectionState ConnectionState { get; private set; } + + /// Creates a new RPC discord client. + public DiscordRpcClient(string clientId) : this(new DiscordRpcConfig(clientId)) { } + /// Creates a new RPC discord client. + public DiscordRpcClient(DiscordRpcConfig config) + { + LogManager = new LogManager(config.LogLevel); + LogManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false); + _clientLogger = LogManager.CreateLogger("Client"); + _rpcLogger = LogManager.CreateLogger("RPC"); + _isFirstLogSub = true; + + _connectionLock = new SemaphoreSlim(1, 1); + + _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + _serializer.Error += (s, e) => + { + _rpcLogger.WarningAsync(e.ErrorContext.Error).GetAwaiter().GetResult(); + e.ErrorContext.Handled = true; + }; + + ApiClient = new API.DiscordRpcApiClient(config.ClientId, config.WebSocketProvider); + ApiClient.SentRpcMessage += async opCode => await _rpcLogger.DebugAsync($"Sent {opCode}").ConfigureAwait(false); + ApiClient.ReceivedRpcEvent += ProcessMessageAsync; + ApiClient.Disconnected += async ex => + { + if (ex != null) + { + await _rpcLogger.WarningAsync($"Connection Closed: {ex.Message}").ConfigureAwait(false); + await StartReconnectAsync(ex).ConfigureAwait(false); + } + else + await _rpcLogger.WarningAsync($"Connection Closed").ConfigureAwait(false); + }; + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + ApiClient.Dispose(); + _isDisposed = true; + } + } + public void Dispose() => Dispose(true); + + /// + public async Task LoginAsync(TokenType tokenType, string token, bool validateToken = true) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await LoginInternalAsync(tokenType, token, validateToken).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task LoginInternalAsync(TokenType tokenType, string token, bool validateToken) + { + if (_isFirstLogSub) + { + _isFirstLogSub = false; + await WriteInitialLog().ConfigureAwait(false); + } + + if (LoginState != LoginState.LoggedOut) + await LogoutInternalAsync().ConfigureAwait(false); + LoginState = LoginState.LoggingIn; + + try + { + await ApiClient.LoginAsync(tokenType, token).ConfigureAwait(false); + + LoginState = LoginState.LoggedIn; + } + catch (Exception) + { + await LogoutInternalAsync().ConfigureAwait(false); + throw; + } + + await _loggedInEvent.InvokeAsync().ConfigureAwait(false); + } + + /// + public async Task LogoutAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await LogoutInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task LogoutInternalAsync() + { + if (LoginState == LoginState.LoggedOut) return; + LoginState = LoginState.LoggingOut; + + await ApiClient.LogoutAsync().ConfigureAwait(false); + + _currentUser = null; + + LoginState = LoginState.LoggedOut; + + await _loggedOutEvent.InvokeAsync().ConfigureAwait(false); + } + + /// + public async Task ConnectAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + _isReconnecting = false; + await ConnectInternalAsync(null).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + public async Task ConnectAndAuthorizeAsync(params string[] scopes) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + _isReconnecting = false; + await ConnectInternalAsync(scopes).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task ConnectInternalAsync(string[] scopes) + { + if (scopes == null && LoginState != LoginState.LoggedIn) + throw new InvalidOperationException("You must log in before connecting or call ConnectAndAuthorizeAsync."); + _scopes = scopes; + + if (_isFirstLogSub) + { + _isFirstLogSub = false; + await WriteInitialLog().ConfigureAwait(false); + } + + var state = ConnectionState; + if (state == ConnectionState.Connecting || state == ConnectionState.Connected) + await DisconnectInternalAsync(null).ConfigureAwait(false); + + ConnectionState = ConnectionState.Connecting; + await _rpcLogger.InfoAsync("Connecting").ConfigureAwait(false); + try + { + _connectTask = new TaskCompletionSource(); + _cancelToken = new CancellationTokenSource(); + await ApiClient.ConnectAsync().ConfigureAwait(false); + await _connectedEvent.InvokeAsync().ConfigureAwait(false); + + /*if (_sessionId != null) + await ApiClient.SendResumeAsync(_sessionId, _lastSeq).ConfigureAwait(false); + else + await ApiClient.SendIdentifyAsync().ConfigureAwait(false);*/ + + await _connectTask.Task.ConfigureAwait(false); + + ConnectionState = ConnectionState.Connected; + await _rpcLogger.InfoAsync("Connected").ConfigureAwait(false); + } + catch (Exception) + { + await DisconnectInternalAsync(null).ConfigureAwait(false); + throw; + } + } + /// + public async Task DisconnectAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + _isReconnecting = false; + await DisconnectInternalAsync(null).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task DisconnectInternalAsync(Exception ex) + { + if (ConnectionState == ConnectionState.Disconnected) return; + ConnectionState = ConnectionState.Disconnecting; + await _rpcLogger.InfoAsync("Disconnecting").ConfigureAwait(false); + + await _rpcLogger.DebugAsync("Disconnecting - CancelToken").ConfigureAwait(false); + //Signal tasks to complete + try { _cancelToken.Cancel(); } catch { } + + await _rpcLogger.DebugAsync("Disconnecting - ApiClient").ConfigureAwait(false); + //Disconnect from server + await ApiClient.DisconnectAsync().ConfigureAwait(false); + + _scopes = null; + ConnectionState = ConnectionState.Disconnected; + await _rpcLogger.InfoAsync("Disconnected").ConfigureAwait(false); + + await _disconnectedEvent.InvokeAsync(ex).ConfigureAwait(false); + } + + private async Task StartReconnectAsync(Exception ex) + { + //TODO: Is this thread-safe? + if (_reconnectTask != null) return; + + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(ex).ConfigureAwait(false); + if (_reconnectTask != null) return; + _isReconnecting = true; + _reconnectTask = ReconnectInternalAsync(); + } + finally { _connectionLock.Release(); } + } + private async Task ReconnectInternalAsync() + { + try + { + int nextReconnectDelay = 1000; + while (_isReconnecting) + { + try + { + await Task.Delay(nextReconnectDelay).ConfigureAwait(false); + nextReconnectDelay *= 2; + if (nextReconnectDelay > 30000) + nextReconnectDelay = 30000; + + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync(_scopes).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + return; + } + catch (Exception ex) + { + await _rpcLogger.WarningAsync("Reconnect failed", ex).ConfigureAwait(false); + } + } + } + finally + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + _isReconnecting = false; + _reconnectTask = null; + } + finally { _connectionLock.Release(); } + } + } + + private async Task ProcessMessageAsync(string cmd, string evnt, object payload, string nonce) + { + try + { + switch (cmd) + { + case "DISPATCH": + switch (evnt) + { + //Connection + case "READY": + { + await _rpcLogger.DebugAsync("Received Dispatch (READY)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + if (_scopes != null) + await ApiClient.SendAuthorizeAsync(_scopes).ConfigureAwait(false); //No bearer + else + await ApiClient.SendAuthenticateAsync().ConfigureAwait(false); //Has bearer + } + break; + + //Others + default: + await _rpcLogger.WarningAsync($"Unknown Dispatch ({evnt})").ConfigureAwait(false); + return; + } + break; + case "AUTHORIZE": + { + await _rpcLogger.DebugAsync("Received AUTHORIZE").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + await ApiClient.LoginAsync(TokenType.Bearer, data.Code).ConfigureAwait(false); + await ApiClient.SendAuthenticateAsync().ConfigureAwait(false); + } + break; + case "AUTHENTICATE": + { + await _rpcLogger.DebugAsync("Received AUTHENTICATE").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var _ = _connectTask.TrySetResultAsync(true); //Signal the .Connect() call to complete + await _rpcLogger.InfoAsync("Ready").ConfigureAwait(false); + } + break; + + default: + await _rpcLogger.WarningAsync($"Unknown OpCode ({cmd})").ConfigureAwait(false); + return; + } + } + catch (Exception ex) + { + await _rpcLogger.ErrorAsync($"Error handling {cmd}{(evnt != null ? $" ({evnt})" : "")}", ex).ConfigureAwait(false); + return; + } + } + + private async Task WriteInitialLog() + { + await _clientLogger.InfoAsync($"DiscordRpcClient v{DiscordRestConfig.Version} (RPC v{DiscordRpcConfig.RpcAPIVersion})").ConfigureAwait(false); + await _clientLogger.VerboseAsync($"Runtime: {RuntimeInformation.FrameworkDescription.Trim()} ({ToArchString(RuntimeInformation.ProcessArchitecture)})").ConfigureAwait(false); + await _clientLogger.VerboseAsync($"OS: {RuntimeInformation.OSDescription.Trim()} ({ToArchString(RuntimeInformation.OSArchitecture)})").ConfigureAwait(false); + await _clientLogger.VerboseAsync($"Processors: {Environment.ProcessorCount}").ConfigureAwait(false); + } + private static string ToArchString(Architecture arch) + { + switch (arch) + { + case Architecture.X64: return "x64"; + case Architecture.X86: return "x86"; + default: return arch.ToString(); + } + } + } +} diff --git a/src/Discord.Net/DiscordRpcConfig.cs b/src/Discord.Net/DiscordRpcConfig.cs new file mode 100644 index 000000000..b120f8399 --- /dev/null +++ b/src/Discord.Net/DiscordRpcConfig.cs @@ -0,0 +1,23 @@ +using Discord.Net.WebSockets; + +namespace Discord +{ + public class DiscordRpcConfig : DiscordConfig + { + public const int RpcAPIVersion = 1; + + public const int PortRangeStart = 6463; + public const int PortRangeEnd = 6472; + + public DiscordRpcConfig(string clientId) + { + ClientId = clientId; + } + + /// Gets or sets the Discord client/application id used for this RPC connection. + public string ClientId { get; set; } + + /// Gets or sets the provider used to generate new websocket connections. + public WebSocketProvider WebSocketProvider { get; set; } = () => new DefaultWebSocketClient(); + } +} diff --git a/src/Discord.Net/DiscordSocketConfig.cs b/src/Discord.Net/DiscordSocketConfig.cs index 8bdb3020a..cbb63e514 100644 --- a/src/Discord.Net/DiscordSocketConfig.cs +++ b/src/Discord.Net/DiscordSocketConfig.cs @@ -14,11 +14,6 @@ namespace Discord /// Gets or sets the number of messages per channel that should be kept in cache. Setting this to zero disables the message cache entirely. public int MessageCacheSize { get; set; } = 0; - /*/// - /// Gets or sets whether the permissions cache should be used. - /// This makes operations such as User.GetPermissions(Channel), User.GuildPermissions, Channel.GetUser, and Channel.Members much faster at the expense of increased memory usage. - /// - public bool UsePermissionsCache { get; set; } = false;*/ /// /// Gets or sets the max number of users a guild may have for offline users to be included in the READY packet. Max is 250. /// Decreasing this may reduce CPU usage while increasing login time and network usage. diff --git a/src/Discord.Net/Entities/Channels/GroupChannel.cs b/src/Discord.Net/Entities/Channels/GroupChannel.cs index 929d050cd..4e0a9024d 100644 --- a/src/Discord.Net/Entities/Channels/GroupChannel.cs +++ b/src/Discord.Net/Entities/Channels/GroupChannel.cs @@ -17,7 +17,7 @@ namespace Discord { protected ConcurrentDictionary _users; private string _iconId; - + public override DiscordRestClient Discord { get; } public string Name { get; private set; } diff --git a/src/Discord.Net/Entities/WebSocket/Channels/MessageManager.cs b/src/Discord.Net/Entities/WebSocket/Channels/MessageManager.cs index 984e66f92..2d47e3190 100644 --- a/src/Discord.Net/Entities/WebSocket/Channels/MessageManager.cs +++ b/src/Discord.Net/Entities/WebSocket/Channels/MessageManager.cs @@ -12,7 +12,7 @@ namespace Discord private readonly DiscordSocketClient _discord; private readonly ISocketMessageChannel _channel; - public virtual IReadOnlyCollection Messages + public virtual IReadOnlyCollection Messages => ImmutableArray.Create(); public MessageManager(DiscordSocketClient discord, ISocketMessageChannel channel) diff --git a/src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs b/src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs index 7d4ebb761..fe95ecb79 100644 --- a/src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs +++ b/src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs @@ -5,7 +5,10 @@ GeneralRest, DirectMessage, SendEditMessage, + GeneralGateway, - UpdateStatus + UpdateStatus, + + GeneralRpc } } diff --git a/src/Discord.Net/Net/Queue/RequestQueue.cs b/src/Discord.Net/Net/Queue/RequestQueue.cs index 826bdd5fa..37e5f816c 100644 --- a/src/Discord.Net/Net/Queue/RequestQueue.cs +++ b/src/Discord.Net/Net/Queue/RequestQueue.cs @@ -36,7 +36,10 @@ namespace Discord.Net.Queue //Gateway [GlobalBucket.GeneralGateway] = new Bucket(null, "gateway", 120, 60, BucketTarget.Both), - [GlobalBucket.UpdateStatus] = new Bucket(null, "status", 5, 1, BucketTarget.Both, GlobalBucket.GeneralGateway) + [GlobalBucket.UpdateStatus] = new Bucket(null, "status", 5, 1, BucketTarget.Both, GlobalBucket.GeneralGateway), + + //Rpc + [GlobalBucket.GeneralRpc] = new Bucket(null, "rpc", 120, 60, BucketTarget.Both) }.ToImmutableDictionary(); _guildLimits = new Dictionary