diff --git a/src/Discord.Net.Net45/Discord.Net.csproj b/src/Discord.Net.Net45/Discord.Net.csproj index dc2902dc7..713ea4180 100644 --- a/src/Discord.Net.Net45/Discord.Net.csproj +++ b/src/Discord.Net.Net45/Discord.Net.csproj @@ -38,7 +38,7 @@ true 6 - + true bin\FullDebug\ TRACE;DEBUG;NET45,TEST_RESPONSES @@ -515,6 +515,9 @@ Net\WebSockets\WebSocketException.cs + + Net\WebSockets\BuiltInEngine.cs + Net\WebSockets\GatewaySocket.cs diff --git a/src/Discord.Net/Net/Rest/BuiltInEngine.cs b/src/Discord.Net/Net/Rest/BuiltInEngine.cs new file mode 100644 index 000000000..ee2deb6a9 --- /dev/null +++ b/src/Discord.Net/Net/Rest/BuiltInEngine.cs @@ -0,0 +1,137 @@ +#if DOTNET5_4 +using Discord.Logging; +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Net.Http; +using System.Net; +using System.Text; +using System.Globalization; + +namespace Discord.Net.Rest +{ + internal sealed class BuiltInEngine : IRestEngine + { + private readonly DiscordConfig _config; + private readonly HttpClient _client; + private readonly string _baseUrl; + + private readonly object _rateLimitLock; + private DateTime _rateLimitTime; + + internal Logger Logger { get; } + + public BuiltInEngine(DiscordConfig config, string baseUrl, Logger logger) + { + _config = config; + _baseUrl = baseUrl; + _rateLimitLock = new object(); + _client = new HttpClient(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, + UseCookies = false, + UseProxy = false, + PreAuthenticate = false //We do auth ourselves + }); + _client.DefaultRequestHeaders.Add("accept", "*/*"); + _client.DefaultRequestHeaders.Add("accept-encoding", "gzip,deflate"); + _client.DefaultRequestHeaders.Add("user-agent", config.UserAgent); + } + + public void SetToken(string token) + { + _client.DefaultRequestHeaders.Remove("authorization"); + if (token != null) + _client.DefaultRequestHeaders.Add("authorization", token); + } + + public async Task Send(string method, string path, string json, CancellationToken cancelToken) + { + using (var request = new HttpRequestMessage(GetMethod(method), _baseUrl + path)) + { + if (json != null) + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + return await Send(request, cancelToken); + } + } + public async Task SendFile(string method, string path, string filename, Stream stream, CancellationToken cancelToken) + { + using (var request = new HttpRequestMessage(GetMethod(method), _baseUrl + path)) + { + var content = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)); + content.Add(new StreamContent(File.OpenRead(path)), "file", filename); + request.Content = content; + return await Send(request, cancelToken); + } + } + private async Task Send(HttpRequestMessage request, CancellationToken cancelToken) + { + int retryCount = 0; + while (true) + { + HttpResponseMessage response; + try + { + response = await _client.SendAsync(request, cancelToken).ConfigureAwait(false); + } + catch (WebException ex) + { + //The request was aborted: Could not create SSL/TLS secure channel. + if (ex.HResult == -2146233079 && retryCount++ < 5) + continue; //Retrying seems to fix this somehow? + throw; + } + + int statusCode = (int)response.StatusCode; + if (statusCode == 429) //Rate limit + { + var retryAfter = response.Headers + .Where(x => x.Key.Equals("Retry-After", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value.FirstOrDefault()) + .FirstOrDefault(); + + int milliseconds; + if (retryAfter != null && int.TryParse(retryAfter, out milliseconds)) + { + var now = DateTime.UtcNow; + if (now >= _rateLimitTime) + { + lock (_rateLimitLock) + { + if (now >= _rateLimitTime) + { + _rateLimitTime = now.AddMilliseconds(milliseconds); + Logger.Warning($"Rate limit hit, waiting {Math.Round(milliseconds / 1000.0f, 2)} seconds"); + } + } + } + await Task.Delay(milliseconds, cancelToken).ConfigureAwait(false); + continue; + } + throw new HttpException(response.StatusCode); + } + else if (statusCode < 200 || statusCode >= 300) //2xx = Success + throw new HttpException(response.StatusCode); + else + return await response.Content.ReadAsStringAsync(); + } + } + + private static readonly HttpMethod _patch = new HttpMethod("PATCH"); + private HttpMethod GetMethod(string method) + { + switch (method) + { + case "DELETE": return HttpMethod.Delete; + case "GET": return HttpMethod.Get; + case "PATCH": return _patch; + case "POST": return HttpMethod.Post; + case "PUT": return HttpMethod.Put; + default: throw new InvalidOperationException($"Unknown HttpMethod: {method}"); + } + } + } +} +#endif \ No newline at end of file diff --git a/src/Discord.Net/Net/Rest/RestClient.cs b/src/Discord.Net/Net/Rest/RestClient.cs index 784fb9208..e5fe1e82c 100644 --- a/src/Discord.Net/Net/Rest/RestClient.cs +++ b/src/Discord.Net/Net/Rest/RestClient.cs @@ -51,7 +51,7 @@ namespace Discord.Net.Rest #if !DOTNET5_4 _engine = new RestSharpEngine(config, baseUrl, logger); #else - //_engine = new BuiltInRestEngine(config, baseUrl, logger); + _engine = new BuiltInEngine(config, baseUrl, logger); #endif } diff --git a/src/Discord.Net/Net/Rest/SharpRestEngine.cs b/src/Discord.Net/Net/Rest/SharpRestEngine.cs index 4a1003a30..b50da9ddd 100644 --- a/src/Discord.Net/Net/Rest/SharpRestEngine.cs +++ b/src/Discord.Net/Net/Rest/SharpRestEngine.cs @@ -51,7 +51,7 @@ namespace Discord.Net.Rest } public Task SendFile(string method, string path, string filename, Stream stream, CancellationToken cancelToken) { - var request = new RestRequest(path, Method.POST); + var request = new RestRequest(path, GetMethod(method)); request.AddHeader("content-length", (stream.Length - stream.Position).ToString()); byte[] bytes = new byte[stream.Length - stream.Position]; @@ -79,6 +79,7 @@ namespace Discord.Net.Rest { var retryAfter = response.Headers .FirstOrDefault(x => x.Name.Equals("Retry-After", StringComparison.OrdinalIgnoreCase)); + int milliseconds; if (retryAfter != null && int.TryParse((string)retryAfter.Value, out milliseconds)) { diff --git a/src/Discord.Net/Net/WebSockets/BuiltInEngine.cs b/src/Discord.Net/Net/WebSockets/BuiltInEngine.cs new file mode 100644 index 000000000..870562474 --- /dev/null +++ b/src/Discord.Net/Net/WebSockets/BuiltInEngine.cs @@ -0,0 +1,163 @@ +#if DOTNET5_4 +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using WebSocketClient = System.Net.WebSockets.ClientWebSocket; + +namespace Discord.Net.WebSockets +{ + internal class BuiltInEngine : IWebSocketEngine + { + private const int ReceiveChunkSize = 4096; + private const int SendChunkSize = 4096; + private const int HR_TIMEOUT = -2147012894; + + private readonly DiscordConfig _config; + private readonly ConcurrentQueue _sendQueue; + private WebSocketClient _webSocket; + + public event EventHandler BinaryMessage = delegate { }; + public event EventHandler TextMessage = delegate { }; + private void OnBinaryMessage(byte[] data) + => BinaryMessage(this, new WebSocketBinaryMessageEventArgs(data)); + private void OnTextMessage(string msg) + => TextMessage(this, new WebSocketTextMessageEventArgs(msg)); + + internal BuiltInEngine(DiscordConfig config) + { + _config = config; + _sendQueue = new ConcurrentQueue(); + } + + public Task Connect(string host, CancellationToken cancelToken) + { + return Task.Run(async () => + { + _webSocket = new WebSocketClient(); + _webSocket.Options.Proxy = null; + _webSocket.Options.SetRequestHeader("User-Agent", _config.UserAgent); + _webSocket.Options.KeepAliveInterval = TimeSpan.Zero; + await _webSocket.ConnectAsync(new Uri(host), cancelToken)//.ConfigureAwait(false); + .ContinueWith(t => ReceiveAsync(cancelToken)).ConfigureAwait(false); + //TODO: ContinueWith is a temporary hack, may be a bug related to https://github.com/dotnet/corefx/issues/4429 + }); + } + + public Task Disconnect() + { + string ignored; + while (_sendQueue.TryDequeue(out ignored)) { } + + var socket = _webSocket; + _webSocket = null; + + return TaskHelper.CompletedTask; + } + + public IEnumerable GetTasks(CancellationToken cancelToken) + => new Task[] { /*ReceiveAsync(cancelToken),*/ SendAsync(cancelToken) }; + + private Task ReceiveAsync(CancellationToken cancelToken) + { + return Task.Run(async () => + { + var sendInterval = _config.WebSocketInterval; + //var buffer = new ArraySegment(new byte[ReceiveChunkSize]); + var buffer = new byte[ReceiveChunkSize]; + var stream = new MemoryStream(); + + try + { + while (!cancelToken.IsCancellationRequested) + { + WebSocketReceiveResult result = null; + do + { + if (cancelToken.IsCancellationRequested) return; + + try + { + result = await _webSocket.ReceiveAsync(new ArraySegment(buffer), cancelToken);//.ConfigureAwait(false); + } + catch (Win32Exception ex) when (ex.HResult == HR_TIMEOUT) + { + throw new Exception($"Connection timed out."); + } + + if (result.MessageType == WebSocketMessageType.Close) + throw new WebSocketException((int)result.CloseStatus.Value, result.CloseStatusDescription); + else + stream.Write(buffer, 0, result.Count); + + } + while (result == null || !result.EndOfMessage); + + var array = stream.ToArray(); + if (result.MessageType == WebSocketMessageType.Binary) + OnBinaryMessage(array); + else if (result.MessageType == WebSocketMessageType.Text) + OnTextMessage(Encoding.UTF8.GetString(array, 0, array.Length)); + + stream.Position = 0; + stream.SetLength(0); + } + } + catch (OperationCanceledException) { } + }); + } + private Task SendAsync(CancellationToken cancelToken) + { + return Task.Run(async () => + { + byte[] bytes = new byte[SendChunkSize]; + var sendInterval = _config.WebSocketInterval; + + try + { + while (!cancelToken.IsCancellationRequested) + { + string json; + while (_sendQueue.TryDequeue(out json)) + { + int byteCount = Encoding.UTF8.GetBytes(json, 0, json.Length, bytes, 0); + int frameCount = (int)Math.Ceiling((double)byteCount / SendChunkSize); + + int offset = 0; + for (var i = 0; i < frameCount; i++, offset += SendChunkSize) + { + bool isLast = i == (frameCount - 1); + + int count; + if (isLast) + count = byteCount - (i * SendChunkSize); + else + count = SendChunkSize; + + try + { + await _webSocket.SendAsync(new ArraySegment(bytes, offset, count), WebSocketMessageType.Text, isLast, cancelToken).ConfigureAwait(false); + } + catch (Win32Exception ex) when (ex.HResult == HR_TIMEOUT) + { + return; + } + } + } + await Task.Delay(sendInterval, cancelToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + }); + } + + public void QueueMessage(string message) + => _sendQueue.Enqueue(message); + } +} +#endif \ No newline at end of file diff --git a/src/Discord.Net/Net/WebSockets/WS4NetEngine.cs b/src/Discord.Net/Net/WebSockets/WS4NetEngine.cs index a5ac22392..3edfeba26 100644 --- a/src/Discord.Net/Net/WebSockets/WS4NetEngine.cs +++ b/src/Discord.Net/Net/WebSockets/WS4NetEngine.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using WebSocket4Net; -using WS4NetWebSocket = WebSocket4Net.WebSocket; +using WebSocketClient = WebSocket4Net.WebSocket; namespace Discord.Net.WebSockets { @@ -15,7 +15,7 @@ namespace Discord.Net.WebSockets private readonly DiscordConfig _config; private readonly ConcurrentQueue _sendQueue; private readonly TaskManager _taskManager; - private WS4NetWebSocket _webSocket; + private WebSocketClient _webSocket; private ManualResetEventSlim _waitUntilConnect; public event EventHandler BinaryMessage = delegate { }; @@ -35,7 +35,7 @@ namespace Discord.Net.WebSockets public Task Connect(string host, CancellationToken cancelToken) { - _webSocket = new WS4NetWebSocket(host); + _webSocket = new WebSocketClient(host); _webSocket.EnableAutoSendPing = false; _webSocket.NoDelay = true; _webSocket.Proxy = null; @@ -96,7 +96,8 @@ namespace Discord.Net.WebSockets private void OnWebSocketBinary(object sender, DataReceivedEventArgs e) => OnBinaryMessage(e.Data); - public IEnumerable GetTasks(CancellationToken cancelToken) => new Task[] { SendAsync(cancelToken) }; + public IEnumerable GetTasks(CancellationToken cancelToken) + => new Task[] { SendAsync(cancelToken) }; private Task SendAsync(CancellationToken cancelToken) { diff --git a/src/Discord.Net/Net/WebSockets/WebSocket.cs b/src/Discord.Net/Net/WebSockets/WebSocket.cs index 93fa641c5..69e86753c 100644 --- a/src/Discord.Net/Net/WebSockets/WebSocket.cs +++ b/src/Discord.Net/Net/WebSockets/WebSocket.cs @@ -53,7 +53,7 @@ namespace Discord.Net.WebSockets #if !DOTNET5_4 _engine = new WS4NetEngine(client.Config, _taskManager); #else - //_engine = new BuiltInWebSocketEngine(this, client.Config); + _engine = new BuiltInEngine(client.Config); #endif _engine.BinaryMessage += (s, e) => { @@ -179,7 +179,8 @@ namespace Discord.Net.WebSockets { //Cancel if either DiscordClient.Disconnect is called, data socket errors or timeout is reached cancelToken = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, CancelToken).Token; - _connectedEvent.Wait(cancelToken); + if (!_connectedEvent.Wait(_client.Config.ConnectionTimeout, cancelToken)) + throw new TimeoutException(); } catch (OperationCanceledException) { diff --git a/src/Discord.Net/project.json b/src/Discord.Net/project.json index 06979fd4d..3610767ef 100644 --- a/src/Discord.Net/project.json +++ b/src/Discord.Net/project.json @@ -32,14 +32,8 @@ "Newtonsoft.Json": "7.0.1" }, - "frameworks": { - "net45": { - "dependencies": { - "WebSocket4Net": "0.14.1", - "RestSharp": "105.2.3" - } - }, - "dotnet5.4": { + "frameworks": { + "dotnet5.4": { "dependencies": { "System.Collections": "4.0.11-beta-23516", "System.Collections.Concurrent": "4.0.11-beta-23516", @@ -47,6 +41,7 @@ "System.IO.FileSystem": "4.0.1-beta-23516", "System.IO.Compression": "4.1.0-beta-23516", "System.Linq": "4.0.1-beta-23516", + "System.Net.Http": "4.0.1-beta-23516", "System.Net.NameResolution": "4.0.0-beta-23516", "System.Net.Sockets": "4.1.0-beta-23409", "System.Net.Requests": "4.0.11-beta-23516", @@ -57,6 +52,12 @@ "System.Threading": "4.0.11-beta-23516", "System.Threading.Thread": "4.0.0-beta-23516" } - } - } + }, + "net45": { + "dependencies": { + "WebSocket4Net": "0.14.1", + "RestSharp": "105.2.3" + } + } + } } \ No newline at end of file