diff --git a/.editorconfig b/.editorconfig index f99bdb0..2b1efbd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -65,7 +65,7 @@ dotnet_style_prefer_auto_properties = true:suggestion dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion dotnet_style_prefer_compound_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:silent dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_inferred_tuple_names = true:suggestion diff --git a/src/StevesBot.Worker/Discord/DiscordCloseCodes.cs b/src/StevesBot.Worker/Discord/DiscordCloseCodes.cs new file mode 100644 index 0000000..60bc62f --- /dev/null +++ b/src/StevesBot.Worker/Discord/DiscordCloseCodes.cs @@ -0,0 +1,21 @@ +namespace StevesBot.Worker.Discord; + +internal static class DiscordCloseCodes +{ + private const int AuthenticationFailed = 4004; + private const int InvalidShard = 4010; + private const int ShardingRequired = 4011; + private const int InvalidAPIVersion = 4012; + private const int InvalidIntents = 4013; + private const int DisallowedIntents = 4014; + + public static bool IsReconnectable(int? closeCode) + { + return closeCode is not AuthenticationFailed + and not InvalidShard + and not ShardingRequired + and not InvalidAPIVersion + and not InvalidIntents + and not DisallowedIntents; + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs b/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs index fcc0c40..4993203 100644 --- a/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs +++ b/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs @@ -4,6 +4,11 @@ namespace StevesBot.Worker.Discord; internal sealed class DiscordGatewayClient : IDiscordGatewayClient { + private const string VersionKey = "v"; + private const string VersionValue = "10"; + private const string EncodingKey = "encoding"; + private const string EncodingValue = "json"; + private readonly DiscordClientOptions _options; private readonly IWebSocketFactory _webSocketFactory; private readonly ILogger _logger; @@ -23,6 +28,7 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient private string _gatewayUrl = string.Empty; private IWebSocket? _webSocket; private int? _lastSequence; + private int? _lastDispatchSequence; private int _heartbeatInterval; private DateTimeOffset _timeLastHeartbeatSent = DateTimeOffset.MinValue; private DateTimeOffset _timeLastHeartbeatAcknowledged = DateTimeOffset.MinValue; @@ -94,11 +100,6 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient while (_linkedReceiveMessageCts?.IsCancellationRequested is false) { - // websocket message might be larger than the - // size of the buffer so we need to loop until - // we receive the end of the message and - // write the data we receive on each iteration - // to the memory stream using var memoryStream = new MemoryStream(); WebSocketReceiveResult result; @@ -108,16 +109,23 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient if (result.MessageType is WebSocketMessageType.Close) { - // TODO: Handle all possible close codes - // reconnect accordingly to documentation - var canResume = result.CloseStatus is not WebSocketCloseStatus.NormalClosure or WebSocketCloseStatus.EndpointUnavailable - && string.IsNullOrWhiteSpace(_resumeGatewayUrl) is false - && string.IsNullOrWhiteSpace(_sessionId) is false; + if (DiscordCloseCodes.IsReconnectable((int?)result.CloseStatus) is false) + { + _logger.LogCritical("Received close status indicating we should not attempt reconnection: {CloseStatus}", result.CloseStatus); + await DisconnectAsync(cancellationToken); + return; + } + + var canResume = IsResumableCloseCode(result.CloseStatus); + var closeStatus = canResume ? WebSocketCloseStatus.Empty : WebSocketCloseStatus.NormalClosure; await SetCanResumeAsync(canResume, _linkedReceiveMessageCts.Token); - await CloseIfOpenAsync(result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, result.CloseStatusDescription, _linkedReceiveMessageCts.Token); - _logger.LogInformation("Reconnecting because of close message: {CloseStatus} - {CloseStatusDescription}", result.CloseStatus, result.CloseStatusDescription); + await CloseIfOpenAsync(closeStatus, result.CloseStatusDescription, _linkedReceiveMessageCts.Token); + + _logger.LogWarning("Reconnecting because of close message: {CloseStatus} - {CloseStatusDescription}", result.CloseStatus, result.CloseStatusDescription); + await ReconnectAsync(cancellationToken); + return; } @@ -165,7 +173,9 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient ); await SetCanResumeAsync(false, newReceiveLinkedCts.Token); - _logger.LogInformation("Reconnecting because of error in receive message task"); + + _logger.LogWarning("Reconnecting because of error in receive message task"); + await ReconnectAsync(cancellationToken); } }, newReceiveLinkedCts.Token); @@ -182,16 +192,77 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient await SetHeartbeatIntervalAsync(he.Data.HeartbeatInterval, cancellationToken); await StartHeartbeatAsync(cancellationToken); await IdentifyAsync(cancellationToken); - _logger.LogInformation("Hello event received. Heartbeat interval: {Interval}", he.Data.HeartbeatInterval); + _logger.LogInformation("Hello event received"); break; case HeartbeatAckDiscordEvent: await SetHeartbeatAcknowledgedAsync(_timeProvider.GetUtcNow(), cancellationToken); _logger.LogInformation("Heartbeat acknowledged"); break; - case ReadyDiscordEvent re: - await SetSessionIdAsync(re.Data.SessionId, cancellationToken); - await SetResumeGatewayUrlAsync(re.Data.ResumeGatewayUrl, cancellationToken); - _logger.LogInformation("Ready event received"); + case HeartbeatDiscordEvent: + _logger.LogInformation("Heartbeat request received"); + await SendHeartbeatAsync(cancellationToken); + break; + case DispatchDiscordEvent de: + await SetDispatchSequenceAsync(e.Sequence, cancellationToken); + + switch (de) + { + case ReadyDiscordEvent re: + await SetSessionIdAsync(re.Data.SessionId, cancellationToken); + await SetResumeGatewayUrlAsync(re.Data.ResumeGatewayUrl, cancellationToken); + _logger.LogInformation("Ready event received"); + break; + default: + _logger.LogInformation("Received dispatch event: {Event}", de.Type ?? "Unknown"); + break; + } + break; + case ReconnectDiscordEvent: + _logger.LogInformation("Reconnect event received"); + + _logger.LogWarning("Closing connection without invalidating session."); + + await SetCanResumeAsync(true, cancellationToken); + + await CloseIfOpenAsync( + WebSocketCloseStatus.Empty, + "Reconnect event received", + cancellationToken + ); + + _logger.LogWarning("Reconnecting because of reconnect event"); + + await ReconnectAsync(cancellationToken); + break; + case InvalidSessionDiscordEvent ise: + _logger.LogInformation("Invalid session event received"); + + if (ise.Data) + { + _logger.LogWarning("Session is resumable. Closing connection without invalidating session."); + + await SetCanResumeAsync(true, cancellationToken); + + await CloseIfOpenAsync( + WebSocketCloseStatus.Empty, + "Invalid session event received", + cancellationToken + ); + } + else + { + _logger.LogWarning("Session is not resumable. Closing connection and invalidating session."); + + await SetCanResumeAsync(false, cancellationToken); + + await CloseIfOpenAsync( + WebSocketCloseStatus.NormalClosure, + "Invalid session event received", + cancellationToken + ); + } + + await ReconnectAsync(cancellationToken); break; default: _logger.LogInformation("Received event: {Event}", e.GetType().Name); @@ -237,7 +308,9 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient } _logger.LogWarning("Heartbeat not acknowledged. Reconnecting."); + await ReconnectAsync(cancellationToken); + break; } @@ -263,7 +336,8 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient newLinkedCts.Token ); - _logger.LogInformation("Reconnecting because of error in heartbeat task"); + _logger.LogWarning("Reconnecting because of error in heartbeat task"); + await ReconnectAsync(cancellationToken); } } @@ -296,7 +370,7 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient using (await _lock.LockAsync(cancellationToken)) { - if (_lastSequence is null) + if (_lastDispatchSequence is null) { throw new DiscordGatewayClientException("Cannot resume without a sequence number."); } @@ -304,7 +378,7 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient resume = new ResumeDiscordEvent( _options.AppToken, _sessionId, - _lastSequence.Value + _lastDispatchSequence.Value ); } @@ -360,6 +434,21 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient } } + private bool IsResumableCloseCode(WebSocketCloseStatus? closeStatus) + { + if (string.IsNullOrWhiteSpace(_resumeGatewayUrl) || string.IsNullOrWhiteSpace(_sessionId)) + { + return false; + } + + if (closeStatus is WebSocketCloseStatus.NormalClosure or WebSocketCloseStatus.EndpointUnavailable) + { + return false; + } + + return true; + } + private bool IsWebSocketOpen() { return _webSocket?.State is WebSocketState.Open; @@ -486,22 +575,19 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient return result; } - private async Task ConnectWithGatewayUrlAsync(CancellationToken cancellationToken) + private Task ConnectWithGatewayUrlAsync(CancellationToken cancellationToken) { - if (_webSocket is null) - { - throw new DiscordGatewayClientException("WebSocket is not set. Cannot connect."); - } - - if (_webSocket.State is WebSocketState.Open) - { - throw new DiscordGatewayClientException("WebSocket is already open. Cannot connect."); - } - - await _webSocket.ConnectAsync(new Uri(_gatewayUrl), cancellationToken); + var uri = BuildUri(_gatewayUrl); + return ConnectWithUriAsync(uri, cancellationToken); } - private async Task ConnectWithResumeUrlAsync(CancellationToken cancellationToken) + private Task ConnectWithResumeUrlAsync(CancellationToken cancellationToken) + { + var uri = BuildUri(_resumeGatewayUrl); + return ConnectWithUriAsync(uri, cancellationToken); + } + + private async Task ConnectWithUriAsync(Uri uri, CancellationToken cancellationToken) { if (_webSocket is null) { @@ -513,7 +599,15 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient throw new DiscordGatewayClientException("WebSocket is already open. Cannot connect."); } - await _webSocket.ConnectAsync(new Uri(_resumeGatewayUrl), cancellationToken); + await _webSocket.ConnectAsync(uri, cancellationToken); + } + + private static Uri BuildUri(string url) + { + return new UriBuilder(url) + { + Query = $"{VersionKey}={VersionValue}&{EncodingKey}={EncodingValue}" + }.Uri; } private async Task SetGatewayUrlAsync(string url, CancellationToken cancellationToken) @@ -522,6 +616,12 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient _gatewayUrl = url; } + private async Task SetDispatchSequenceAsync(int? sequence, CancellationToken cancellationToken) + { + using var _ = await _lock.LockAsync(cancellationToken); + _lastDispatchSequence = sequence; + } + private async Task SetSequenceAsync(int? sequence, CancellationToken cancellationToken) { using var _ = await _lock.LockAsync(cancellationToken); diff --git a/src/StevesBot.Worker/Discord/Events/DiscordEventConverter.cs b/src/StevesBot.Worker/Discord/Events/DiscordEventConverter.cs index 29afce6..77020ad 100644 --- a/src/StevesBot.Worker/Discord/Events/DiscordEventConverter.cs +++ b/src/StevesBot.Worker/Discord/Events/DiscordEventConverter.cs @@ -14,6 +14,9 @@ internal sealed class DiscordEventConverter : JsonConverter return op switch { DiscordOpCodes.Dispatch => DeserializeDispatchEvent(root, options, modifiedOptions), + DiscordOpCodes.Heartbeat => JsonSerializer.Deserialize(root.GetRawText(), options), + DiscordOpCodes.Reconnect => JsonSerializer.Deserialize(root.GetRawText(), options), + DiscordOpCodes.InvalidSession => JsonSerializer.Deserialize(root.GetRawText(), options), DiscordOpCodes.Hello => JsonSerializer.Deserialize(root.GetRawText(), options), DiscordOpCodes.HeartbeatAck => JsonSerializer.Deserialize(root.GetRawText(), options), _ => JsonSerializer.Deserialize(root.GetRawText(), modifiedOptions), @@ -26,14 +29,14 @@ internal sealed class DiscordEventConverter : JsonConverter JsonSerializer.Serialize(writer, value, value.GetType(), modifiedOptions); } - private static DiscordEvent? DeserializeDispatchEvent(JsonElement root, JsonSerializerOptions options, JsonSerializerOptions modifiedOptions) + private static DispatchDiscordEvent? DeserializeDispatchEvent(JsonElement root, JsonSerializerOptions options, JsonSerializerOptions modifiedOptions) { var type = root.GetProperty("t").GetString(); return type switch { DiscordEventTypes.Ready => JsonSerializer.Deserialize(root.GetRawText(), options), - _ => JsonSerializer.Deserialize(root.GetRawText(), modifiedOptions), + _ => JsonSerializer.Deserialize(root.GetRawText(), modifiedOptions), }; } diff --git a/src/StevesBot.Worker/Discord/Events/DiscordOpCodes.cs b/src/StevesBot.Worker/Discord/Events/DiscordOpCodes.cs index 7239355..cf520d1 100644 --- a/src/StevesBot.Worker/Discord/Events/DiscordOpCodes.cs +++ b/src/StevesBot.Worker/Discord/Events/DiscordOpCodes.cs @@ -6,6 +6,8 @@ internal static class DiscordOpCodes public const int Heartbeat = 1; public const int Identify = 2; public const int Resume = 6; + public const int Reconnect = 7; + public const int InvalidSession = 9; public const int HeartbeatAck = 11; public const int Hello = 10; } \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Events/DispatchDiscordEvent.cs b/src/StevesBot.Worker/Discord/Events/DispatchDiscordEvent.cs new file mode 100644 index 0000000..f26b539 --- /dev/null +++ b/src/StevesBot.Worker/Discord/Events/DispatchDiscordEvent.cs @@ -0,0 +1,5 @@ +namespace StevesBot.Worker.Discord.Events; + +internal record DispatchDiscordEvent : DiscordEvent +{ +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Events/InvalidSessionDiscordEvent.cs b/src/StevesBot.Worker/Discord/Events/InvalidSessionDiscordEvent.cs new file mode 100644 index 0000000..a6b951e --- /dev/null +++ b/src/StevesBot.Worker/Discord/Events/InvalidSessionDiscordEvent.cs @@ -0,0 +1,7 @@ +namespace StevesBot.Worker.Discord.Events; + +internal sealed record InvalidSessionDiscordEvent : DiscordEvent +{ + [JsonPropertyName("d")] + public new bool Data { get; init; } +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Events/ReadyDiscordEvent.cs b/src/StevesBot.Worker/Discord/Events/ReadyDiscordEvent.cs index ce16476..9313866 100644 --- a/src/StevesBot.Worker/Discord/Events/ReadyDiscordEvent.cs +++ b/src/StevesBot.Worker/Discord/Events/ReadyDiscordEvent.cs @@ -1,6 +1,6 @@ namespace StevesBot.Worker.Discord.Events; -internal record ReadyDiscordEvent : DiscordEvent +internal record ReadyDiscordEvent : DispatchDiscordEvent { [JsonPropertyName("d")] public new ReadyData Data { get; init; } = new ReadyData(); @@ -16,4 +16,4 @@ internal record ReadyData [JsonPropertyName("resume_gateway_url")] public string ResumeGatewayUrl { get; init; } = string.Empty; -} +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Events/ReconnectDiscordEvent.cs b/src/StevesBot.Worker/Discord/Events/ReconnectDiscordEvent.cs new file mode 100644 index 0000000..a44d4e6 --- /dev/null +++ b/src/StevesBot.Worker/Discord/Events/ReconnectDiscordEvent.cs @@ -0,0 +1,5 @@ +namespace StevesBot.Worker.Discord; + +internal sealed record ReconnectDiscordEvent : DiscordEvent +{ +} \ No newline at end of file