diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordRestClientTests.cs b/src/StevesBot.Worker.Tests/Unit/DiscordRestClientTests.cs new file mode 100644 index 0000000..4c7235e --- /dev/null +++ b/src/StevesBot.Worker.Tests/Unit/DiscordRestClientTests.cs @@ -0,0 +1,73 @@ +using System.Net; + +using Microsoft.Extensions.Logging; + +using Moq; + +using RichardSzalay.MockHttp; + +namespace StevesBot.Worker.Tests.Unit; + +public sealed class DiscordRestClientTests : IDisposable +{ + private const string BaseUrl = "https://discord.com/api/v10"; + private static string GatewayUrl => $"{BaseUrl}/gateway"; + + private readonly Mock> _loggerMock = new(); + private readonly MockHttpMessageHandler _mockHttpMessageHandler; + private readonly DiscordRestClient _discordRestClient; + + public DiscordRestClientTests() + { + _mockHttpMessageHandler = new MockHttpMessageHandler(); + var httpClient = _mockHttpMessageHandler.ToHttpClient(); + httpClient.BaseAddress = new Uri("https://discord.com/api/v10/"); + _discordRestClient = new DiscordRestClient(_loggerMock.Object, httpClient); + } + + [Fact] + public async Task GetGatewayUrlAsync_WhenRequestFails_ItShouldThrowException() + { + _mockHttpMessageHandler + .When(GatewayUrl) + .Respond(HttpStatusCode.InternalServerError); + + var act = async () => await _discordRestClient.GetGatewayUrlAsync(CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetGatewayUrlAsync_WhenResponseIsNull_ItShouldThrowException() + { + _mockHttpMessageHandler + .When(GatewayUrl) + .Respond(HttpStatusCode.OK, "application/json", "null"); + + var act = async () => await _discordRestClient.GetGatewayUrlAsync(CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetGatewayUrlAsync_WhenRequestIsSuccessful_ItShouldReturnGatewayUrl() + { + var expectedUrl = "test"; + var jsonResponse = $@"{{ + ""url"": ""{expectedUrl}"" + }}"; + + _mockHttpMessageHandler + .When(GatewayUrl) + .Respond(HttpStatusCode.OK, "application/json", jsonResponse); + + var result = await _discordRestClient.GetGatewayUrlAsync(CancellationToken.None); + + result.Should().Be(expectedUrl); + } + + public void Dispose() + { + _mockHttpMessageHandler.Dispose(); + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs b/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs index fd4a9e0..1ae3f27 100644 --- a/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs +++ b/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs @@ -5,44 +5,73 @@ internal class DiscordGatewayClient : IDiscordGatewayClient private readonly DiscordGatewayClientOptions _options; private readonly IWebSocketFactory _webSocketFactory; private readonly ILogger _logger; - private readonly JsonSerializerOptions _jsonSerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReferenceHandler = ReferenceHandler.IgnoreCycles, - Converters = - { - new DiscordEventConverter(), - }, - }; + private readonly IDiscordRestClient _discordRestClient; + // private readonly JsonSerializerOptions _jsonSerializerOptions = new() + // { + // PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + // ReferenceHandler = ReferenceHandler.IgnoreCycles, + // Converters = + // { + // new DiscordEventConverter(), + // }, + // }; private readonly AsyncLock _lock = new(); private string _gatewayUrl = string.Empty; private IWebSocket? _webSocket; private Task? _receiveTask; - private DateTime _timeLastHeartbeatSent = DateTime.MinValue; - private DateTime _timeLastHeartbeatAcknowledged = DateTime.MinValue; - private CancellationTokenSource? _heartbeatCts; - private Task? _heartbeatTask; - private string _sessionId = string.Empty; - private string _resumeGatewayUrl = string.Empty; + // private DateTime _timeLastHeartbeatSent = DateTime.MinValue; + // private DateTime _timeLastHeartbeatAcknowledged = DateTime.MinValue; + // private CancellationTokenSource? _heartbeatCts; + // private Task? _heartbeatTask; + // private string _sessionId = string.Empty; + // private string _resumeGatewayUrl = string.Empty; public DiscordGatewayClient( DiscordGatewayClientOptions options, IWebSocketFactory webSocketFactory, - ILogger logger + ILogger logger, + IDiscordRestClient discordRestClient ) { _options = options ?? throw new ArgumentNullException(nameof(options)); _webSocketFactory = webSocketFactory ?? throw new ArgumentNullException(nameof(webSocketFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _discordRestClient = discordRestClient ?? throw new ArgumentNullException(nameof(discordRestClient)); + } + + public async Task ConnectAsync(CancellationToken cancellationToken) + { + using (await _lock.LockAsync(cancellationToken)) + { + if (string.IsNullOrEmpty(_gatewayUrl)) + { + _gatewayUrl = await _discordRestClient.GetGatewayUrlAsync(cancellationToken); + } + + _webSocket = _webSocketFactory.Create(); + + var uri = new Uri(_gatewayUrl); + await _webSocket.ConnectAsync(uri, cancellationToken); + + _logger.LogInformation("Connected to Discord Gateway at {GatewayUrl}", _gatewayUrl); + + _receiveTask = ReceiveMessagesAsync(cancellationToken); + } + } + + private static Task ReceiveMessagesAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; } public void Dispose() { - _heartbeatCts?.Cancel(); - _heartbeatCts?.Dispose(); - _heartbeatTask?.Dispose(); + // _heartbeatCts?.Cancel(); + // _heartbeatCts?.Dispose(); + // _heartbeatTask?.Dispose(); _receiveTask?.Dispose(); _webSocket?.Dispose(); + _lock.Dispose(); } } \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/DiscordRestClient.cs b/src/StevesBot.Worker/Discord/DiscordRestClient.cs new file mode 100644 index 0000000..7a9d651 --- /dev/null +++ b/src/StevesBot.Worker/Discord/DiscordRestClient.cs @@ -0,0 +1,43 @@ +namespace StevesBot.Worker.Discord; + +internal class DiscordRestClient : IDiscordRestClient +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + public DiscordRestClient( + ILogger logger, + HttpClient httpClient + ) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + + public async Task GetGatewayUrlAsync(CancellationToken cancellationToken) + { + var uri = new Uri("gateway", UriKind.Relative); + var response = await _httpClient.GetAsync(uri, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to get gateway URL: {StatusCode}", response.StatusCode); + throw new DiscordRestClientException("Failed to get gateway URL."); + } + + var gatewayResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (gatewayResponse is null) + { + _logger.LogError("Failed to deserialize gateway response."); + throw new DiscordRestClientException("Failed to deserialize gateway response."); + } + + return gatewayResponse.Url; + } +} + +internal record GatewayResponse( + [property: JsonPropertyName("url")] string Url +); diff --git a/src/StevesBot.Worker/Discord/DiscordRestClientException.cs b/src/StevesBot.Worker/Discord/DiscordRestClientException.cs new file mode 100644 index 0000000..583f8c1 --- /dev/null +++ b/src/StevesBot.Worker/Discord/DiscordRestClientException.cs @@ -0,0 +1,16 @@ +namespace StevesBot.Worker.Discord; + +internal class DiscordRestClientException : Exception +{ + public DiscordRestClientException() + { + } + + public DiscordRestClientException(string message) : base(message) + { + } + + public DiscordRestClientException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/IDiscordGatewayClient.cs b/src/StevesBot.Worker/Discord/IDiscordGatewayClient.cs index e541355..1a619fa 100644 --- a/src/StevesBot.Worker/Discord/IDiscordGatewayClient.cs +++ b/src/StevesBot.Worker/Discord/IDiscordGatewayClient.cs @@ -2,4 +2,5 @@ namespace StevesBot.Worker.Discord; internal interface IDiscordGatewayClient : IDisposable { + Task ConnectAsync(CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/IDiscordRestClient.cs b/src/StevesBot.Worker/Discord/IDiscordRestClient.cs new file mode 100644 index 0000000..4a46f09 --- /dev/null +++ b/src/StevesBot.Worker/Discord/IDiscordRestClient.cs @@ -0,0 +1,6 @@ +namespace StevesBot.Worker.Discord; + +internal interface IDiscordRestClient +{ + Task GetGatewayUrlAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Usings.cs b/src/StevesBot.Worker/Usings.cs index 0c147cc..93deb38 100644 --- a/src/StevesBot.Worker/Usings.cs +++ b/src/StevesBot.Worker/Usings.cs @@ -1,8 +1,8 @@ +global using System.Net.Http.Json; global using System.Net.WebSockets; global using System.Text.Json; global using System.Text.Json.Serialization; - global using StevesBot.Worker.Discord.Events; global using StevesBot.Worker.Threading; global using StevesBot.Worker.WebSockets; \ No newline at end of file