From acfddee092857baee11d5fd472a879db8af7495a Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Fri, 23 Aug 2024 08:45:07 +0200 Subject: [PATCH 01/33] Implement basics --- NetCord/Gateway/GatewayClient.cs | 25 +- NetCord/Gateway/GatewayClientConfiguration.cs | 6 +- NetCord/Gateway/GatewayRateLimiter.cs | 45 ++++ NetCord/Gateway/IRateLimiter.cs | 8 + .../Gateway/IWebSocketClientConfiguration.cs | 4 +- NetCord/Gateway/NullRateLimiter.cs | 20 ++ NetCord/Gateway/RateLimitAcquisitionResult.cs | 18 ++ NetCord/Gateway/RentedArrayBufferWriter.cs | 2 +- NetCord/Gateway/ShardedGatewayClient.cs | 8 +- .../ShardedGatewayClientConfiguration.cs | 4 +- NetCord/Gateway/Voice/VoiceClient.cs | 10 +- .../Gateway/Voice/VoiceClientConfiguration.cs | 5 +- NetCord/Gateway/WebSocketClient.cs | 212 ++++++++++++++--- NetCord/Gateway/WebSocketPayloadProperties.cs | 10 + NetCord/Gateway/WebSocketRetryHandling.cs | 10 + NetCord/Gateway/WebSockets/IWebSocket.cs | 32 --- .../WebSockets/IWebSocketConnection.cs | 16 ++ .../IWebSocketConnectionProvider.cs | 6 + NetCord/Gateway/WebSockets/WebSocket.cs | 220 ------------------ .../Gateway/WebSockets/WebSocketConnection.cs | 50 ++++ .../WebSockets/WebSocketConnectionProvider.cs | 9 + .../WebSocketConnectionReceiveResult.cs | 27 +++ .../WebSockets/WebSocketMessageFlags.cs | 8 + .../WebSockets/WebSocketMessageType.cs | 8 + NetCord/Rest/RateLimitedException.cs | 2 +- Tests/NetCord.Test/Program.cs | 26 ++- 26 files changed, 478 insertions(+), 313 deletions(-) create mode 100644 NetCord/Gateway/GatewayRateLimiter.cs create mode 100644 NetCord/Gateway/IRateLimiter.cs create mode 100644 NetCord/Gateway/NullRateLimiter.cs create mode 100644 NetCord/Gateway/RateLimitAcquisitionResult.cs create mode 100644 NetCord/Gateway/WebSocketPayloadProperties.cs create mode 100644 NetCord/Gateway/WebSocketRetryHandling.cs delete mode 100644 NetCord/Gateway/WebSockets/IWebSocket.cs create mode 100644 NetCord/Gateway/WebSockets/IWebSocketConnection.cs create mode 100644 NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs delete mode 100644 NetCord/Gateway/WebSockets/WebSocket.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketConnection.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketConnectionReceiveResult.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketMessageType.cs diff --git a/NetCord/Gateway/GatewayClient.cs b/NetCord/Gateway/GatewayClient.cs index c3e6c98c0..19ccadd19 100644 --- a/NetCord/Gateway/GatewayClient.cs +++ b/NetCord/Gateway/GatewayClient.cs @@ -9,7 +9,7 @@ namespace NetCord.Gateway; /// -/// The GatewayClient class allows applications to send and receive data from the Discord Gateway, such as events and resource requests, via a WebSocket client. +/// The class allows applications to send and receive data from the Discord Gateway, such as events and resource requests. /// public partial class GatewayClient : WebSocketClient, IEntity { @@ -370,7 +370,7 @@ public partial class GatewayClient : WebSocketClient, IEntity public event Func? GuildUserUpdate; /// - /// Sent in response to . You can use the and to calculate how many chunks are left for your request.
+ /// Sent in response to . You can use the and to calculate how many chunks are left for your request.
///
/// ///
Required Intents: None @@ -847,7 +847,7 @@ private ValueTask SendIdentifyAsync(PresenceProperties? presence = null, Cancell Intents = _configuration.Intents, }).Serialize(Serialization.Default.GatewayPayloadPropertiesGatewayIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } /// @@ -887,14 +887,14 @@ private ValueTask TryResumeAsync(string sessionId, int sequenceNumber, Cancellat { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Resume, new(Token.RawToken, sessionId, sequenceNumber)).Serialize(Serialization.Default.GatewayPayloadPropertiesGatewayResumeProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } private protected override ValueTask HeartbeatAsync(CancellationToken cancellationToken = default) { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Heartbeat, SequenceNumber).Serialize(Serialization.Default.GatewayPayloadPropertiesInt32); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } private protected override JsonPayload CreatePayload(ReadOnlyMemory payload) => JsonSerializer.Deserialize(_compression.Decompress(payload).Span, Serialization.Default.JsonPayload)!; @@ -943,30 +943,31 @@ private protected override async Task ProcessPayloadAsync(JsonPayload payload) /// /// Joins, moves, or disconnects the app from a voice channel. /// - public ValueTask UpdateVoiceStateAsync(VoiceStateProperties voiceState, CancellationToken cancellationToken = default) + public ValueTask UpdateVoiceStateAsync(VoiceStateProperties voiceState, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { GatewayPayloadProperties payload = new(GatewayOpcode.VoiceStateUpdate, voiceState); - return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesVoiceStateProperties), cancellationToken); + return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesVoiceStateProperties), properties, cancellationToken); } /// /// Updates an app's presence. /// /// The presence to set. + /// /// The cancellation token to cancel the operation. - public ValueTask UpdatePresenceAsync(PresenceProperties presence, CancellationToken cancellationToken = default) + public ValueTask UpdatePresenceAsync(PresenceProperties presence, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { GatewayPayloadProperties payload = new(GatewayOpcode.PresenceUpdate, presence); - return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesPresenceProperties), cancellationToken); + return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesPresenceProperties), properties, cancellationToken); } /// - /// Requests user for a guild. + /// Requests users for a guild. /// - public ValueTask RequestGuildUsersAsync(GuildUsersRequestProperties requestProperties, CancellationToken cancellationToken = default) + public ValueTask RequestGuildUsersAsync(GuildUsersRequestProperties requestProperties, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { GatewayPayloadProperties payload = new(GatewayOpcode.RequestGuildUsers, requestProperties); - return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesGuildUsersRequestProperties), cancellationToken); + return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesGuildUsersRequestProperties), properties, cancellationToken); } private async Task ProcessEventAsync(JsonPayload payload) diff --git a/NetCord/Gateway/GatewayClientConfiguration.cs b/NetCord/Gateway/GatewayClientConfiguration.cs index 2621c882b..7edba9551 100644 --- a/NetCord/Gateway/GatewayClientConfiguration.cs +++ b/NetCord/Gateway/GatewayClientConfiguration.cs @@ -7,7 +7,9 @@ namespace NetCord.Gateway; public class GatewayClientConfiguration : IWebSocketClientConfiguration { - public IWebSocket? WebSocket { get; init; } + public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; init; } + public IRateLimiter? RateLimiter { get; init; } + public WebSocketPayloadProperties? DefaultPayloadProperties { get; init; } public IReconnectStrategy? ReconnectStrategy { get; init; } public ILatencyTimer? LatencyTimer { get; init; } public ApiVersion Version { get; init; } = ApiVersion.V10; @@ -21,4 +23,6 @@ public class GatewayClientConfiguration : IWebSocketClientConfiguration public Shard? Shard { get; init; } public bool CacheDMChannels { get; init; } = true; public Rest.RestClientConfiguration? RestClientConfiguration { get; init; } + + IRateLimiter? IWebSocketClientConfiguration.RateLimiter => RateLimiter is { } rateLimiter ? rateLimiter : new GatewayRateLimiter(120, 60_000); } diff --git a/NetCord/Gateway/GatewayRateLimiter.cs b/NetCord/Gateway/GatewayRateLimiter.cs new file mode 100644 index 000000000..433ae1bee --- /dev/null +++ b/NetCord/Gateway/GatewayRateLimiter.cs @@ -0,0 +1,45 @@ +namespace NetCord.Gateway; + +public sealed class GatewayRateLimiter(int limit, long duration) : IRateLimiter +{ + private readonly object _lock = new(); + private readonly int _limit = limit; + private int _remaining = limit; + private long _reset; + + public ValueTask TryAcquireAsync() + { + var timestamp = Environment.TickCount64; + lock (_lock) + { + var diff = _reset - timestamp; + if (diff <= 0) + { + _remaining = _limit - 1; + _reset = timestamp + duration; + } + else + { + if (_remaining == 0) + return new(RateLimitAcquisitionResult.RateLimit((int)diff)); + else + _remaining--; + } + } + + return new(RateLimitAcquisitionResult.NoRateLimit()); + } + + public void Reset() + { + lock (_lock) + { + _remaining = _limit; + _reset = 0; + } + } + + public void Dispose() + { + } +} diff --git a/NetCord/Gateway/IRateLimiter.cs b/NetCord/Gateway/IRateLimiter.cs new file mode 100644 index 000000000..c7a25ace4 --- /dev/null +++ b/NetCord/Gateway/IRateLimiter.cs @@ -0,0 +1,8 @@ +namespace NetCord.Gateway; + +public interface IRateLimiter : IDisposable +{ + public ValueTask TryAcquireAsync(); + + public void Reset(); +} diff --git a/NetCord/Gateway/IWebSocketClientConfiguration.cs b/NetCord/Gateway/IWebSocketClientConfiguration.cs index 10b12b1a3..131f763e3 100644 --- a/NetCord/Gateway/IWebSocketClientConfiguration.cs +++ b/NetCord/Gateway/IWebSocketClientConfiguration.cs @@ -6,7 +6,9 @@ namespace NetCord.Gateway; internal interface IWebSocketClientConfiguration { - public IWebSocket? WebSocket { get; } + public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; } public IReconnectStrategy? ReconnectStrategy { get; } public ILatencyTimer? LatencyTimer { get; } + public IRateLimiter? RateLimiter { get; } + public WebSocketPayloadProperties? DefaultPayloadProperties { get; } } diff --git a/NetCord/Gateway/NullRateLimiter.cs b/NetCord/Gateway/NullRateLimiter.cs new file mode 100644 index 000000000..f03ad4929 --- /dev/null +++ b/NetCord/Gateway/NullRateLimiter.cs @@ -0,0 +1,20 @@ +namespace NetCord.Gateway; + +internal sealed class NullRateLimiter : IRateLimiter +{ + public static NullRateLimiter Instance { get; } = new(); + + private NullRateLimiter() + { + } + + public ValueTask TryAcquireAsync() => new(RateLimitAcquisitionResult.NoRateLimit()); + + public void Reset() + { + } + + public void Dispose() + { + } +} diff --git a/NetCord/Gateway/RateLimitAcquisitionResult.cs b/NetCord/Gateway/RateLimitAcquisitionResult.cs new file mode 100644 index 000000000..f3c97a851 --- /dev/null +++ b/NetCord/Gateway/RateLimitAcquisitionResult.cs @@ -0,0 +1,18 @@ +namespace NetCord.Gateway; + +public readonly struct RateLimitAcquisitionResult +{ + private RateLimitAcquisitionResult(int resetAfter, bool rateLimited) + { + ResetAfter = resetAfter; + RateLimited = rateLimited; + } + + public static RateLimitAcquisitionResult NoRateLimit() => new(0, false); + + public static RateLimitAcquisitionResult RateLimit(int resetAfter) => new(resetAfter, true); + + public int ResetAfter { get; } + + public bool RateLimited { get; } +} diff --git a/NetCord/Gateway/RentedArrayBufferWriter.cs b/NetCord/Gateway/RentedArrayBufferWriter.cs index eb4ceddfb..964ce239a 100644 --- a/NetCord/Gateway/RentedArrayBufferWriter.cs +++ b/NetCord/Gateway/RentedArrayBufferWriter.cs @@ -57,7 +57,7 @@ private void ResizeBuffer(int sizeHint) { var pool = ArrayPool.Shared; var newBuffer = pool.Rent(sum); - Array.Copy(buffer, newBuffer, index); + buffer.AsSpan(0, index).CopyTo(newBuffer); _buffer = newBuffer; pool.Return(buffer); } diff --git a/NetCord/Gateway/ShardedGatewayClient.cs b/NetCord/Gateway/ShardedGatewayClient.cs index 4e8481071..7add3bfde 100644 --- a/NetCord/Gateway/ShardedGatewayClient.cs +++ b/NetCord/Gateway/ShardedGatewayClient.cs @@ -31,7 +31,7 @@ private static ShardedGatewayClientConfiguration CreateConfiguration(ShardedGate { return new() { - WebSocketFactory = _ => null, + WebSocketConnectionProviderFactory = _ => null, ReconnectStrategyFactory = _ => null, LatencyTimerFactory = _ => null, VersionFactory = _ => ApiVersion.V10, @@ -49,7 +49,7 @@ private static ShardedGatewayClientConfiguration CreateConfiguration(ShardedGate return new() { - WebSocketFactory = configuration.WebSocketFactory ?? (_ => null), + WebSocketConnectionProviderFactory = configuration.WebSocketConnectionProviderFactory ?? (_ => null), ReconnectStrategyFactory = configuration.ReconnectStrategyFactory ?? (_ => null), LatencyTimerFactory = configuration.LatencyTimerFactory ?? (_ => null), VersionFactory = configuration.VersionFactory ?? (_ => ApiVersion.V10), @@ -219,7 +219,9 @@ private GatewayClientConfiguration GetGatewayClientConfiguration(Shard shard) var configuration = _configuration; return new() { - WebSocket = configuration.WebSocketFactory!(shard), + WebSocketConnectionProvider = configuration.WebSocketConnectionProviderFactory!(shard), + RateLimiter = configuration.RateLimiterFactory!(shard), + DefaultPayloadProperties = configuration.DefaultPayloadPropertiesFactory!(shard), ReconnectStrategy = configuration.ReconnectStrategyFactory!(shard), LatencyTimer = configuration.LatencyTimerFactory!(shard), Version = configuration.VersionFactory!(shard), diff --git a/NetCord/Gateway/ShardedGatewayClientConfiguration.cs b/NetCord/Gateway/ShardedGatewayClientConfiguration.cs index 0cce73dd5..24718ee9c 100644 --- a/NetCord/Gateway/ShardedGatewayClientConfiguration.cs +++ b/NetCord/Gateway/ShardedGatewayClientConfiguration.cs @@ -7,7 +7,9 @@ namespace NetCord.Gateway; public class ShardedGatewayClientConfiguration { - public Func? WebSocketFactory { get; init; } + public Func? WebSocketConnectionProviderFactory { get; init; } + public Func? RateLimiterFactory { get; init; } + public Func? DefaultPayloadPropertiesFactory { get; init; } public Func? ReconnectStrategyFactory { get; init; } public Func? LatencyTimerFactory { get; init; } public Func? VersionFactory { get; init; } diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index 06a58acf7..c42cfb6e4 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -56,7 +56,7 @@ private ValueTask SendIdentifyAsync(CancellationToken cancellationToken = defaul { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Identify, new(GuildId, UserId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } /// @@ -86,14 +86,14 @@ private protected override ValueTask TryResumeAsync(CancellationToken cancellati { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Resume, new(GuildId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceResumeProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } private protected override ValueTask HeartbeatAsync(CancellationToken cancellationToken = default) { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Heartbeat, Environment.TickCount).Serialize(Serialization.Default.VoicePayloadPropertiesInt32); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } private protected override async Task ProcessPayloadAsync(JsonPayload payload) @@ -240,10 +240,10 @@ private async void HandleDatagramReceive(UdpReceiveResult obj) } } - public ValueTask EnterSpeakingStateAsync(SpeakingFlags flags, int delay = 0, CancellationToken cancellationToken = default) + public ValueTask EnterSpeakingStateAsync(SpeakingFlags flags, int delay = 0, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { VoicePayloadProperties payload = new(VoiceOpcode.Speaking, new(flags, delay, Cache.Ssrc)); - return SendPayloadAsync(payload.Serialize(Serialization.Default.VoicePayloadPropertiesSpeakingProperties), cancellationToken); + return SendPayloadAsync(payload.Serialize(Serialization.Default.VoicePayloadPropertiesSpeakingProperties), properties, cancellationToken); } /// diff --git a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs index fa8c8ecce..5d581f42b 100644 --- a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs +++ b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs @@ -8,7 +8,8 @@ namespace NetCord.Gateway.Voice; public class VoiceClientConfiguration : IWebSocketClientConfiguration { - public IWebSocket? WebSocket { get; init; } + public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; init; } + public WebSocketPayloadProperties? DefaultPayloadProperties { get; init; } public IUdpSocket? UdpSocket { get; init; } public IReconnectStrategy? ReconnectStrategy { get; init; } public ILatencyTimer? LatencyTimer { get; init; } @@ -16,4 +17,6 @@ public class VoiceClientConfiguration : IWebSocketClientConfiguration public IVoiceClientCache? Cache { get; init; } public IVoiceEncryption? Encryption { get; init; } public bool RedirectInputStreams { get; init; } + + IRateLimiter? IWebSocketClientConfiguration.RateLimiter => null; } diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 6e64c07c6..9cd1bebca 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -12,30 +12,68 @@ namespace NetCord.Gateway; public abstract class WebSocketClient : IDisposable { - private protected WebSocketClient(IWebSocketClientConfiguration configuration) + private sealed class State(IWebSocketConnection connection) : IDisposable { - var webSocket = configuration.WebSocket ?? new WebSocket(); + public IWebSocketConnection Connection { get; } = connection; + + public CancellationTokenProvider DisconnectedTokenProvider { get; } = new(); + + public Task ReadTask => _readCompletionSource.Task; + + public TaskCompletionSource _readCompletionSource = new(); + + public Task ReadyTask => _readCompletionSource.Task; + + public TaskCompletionSource _readyCompletionSource = new(); + + private int _state; + + public async void StartReading(Func readAsync) + { + await readAsync(this).ConfigureAwait(false); + + _readCompletionSource.TrySetResult(); + } - webSocket.Connecting += HandleConnecting; - webSocket.Connected += HandleConnected; - webSocket.Disconnected += HandleDisconnected; - webSocket.Closed += HandleClosed; - webSocket.MessageReceived += HandleMessageReceived; + public bool TryIndicateDisconnecting() + { + var disconnecting = Interlocked.Exchange(ref _state, 1) is 0; + + if (disconnecting) + DisconnectedTokenProvider.Cancel(); + + return disconnecting; + } - _webSocket = webSocket; + public void Dispose() + { + DisconnectedTokenProvider.Dispose(); + Connection.Dispose(); + } + } + + private const int DefaultBufferSize = 8192; + + private protected WebSocketClient(IWebSocketClientConfiguration configuration) + { + _connectionProvider = configuration.WebSocketConnectionProvider ?? new WebSocketConnectionProvider(); _reconnectStrategy = configuration.ReconnectStrategy ?? new ReconnectStrategy(); _latencyTimer = configuration.LatencyTimer ?? new LatencyTimer(); + _rateLimiter = configuration.RateLimiter ?? NullRateLimiter.Instance; + _defaultPayloadProperties = configuration.DefaultPayloadProperties is { } defaultPayloadProperties ? defaultPayloadProperties with { } : new(); } private readonly object _eventsLock = new(); - private readonly IWebSocket _webSocket; + private readonly IWebSocketConnectionProvider _connectionProvider; private readonly IReconnectStrategy _reconnectStrategy; + private readonly IRateLimiter _rateLimiter; + private readonly WebSocketPayloadProperties _defaultPayloadProperties; private protected readonly ILatencyTimer _latencyTimer; private protected readonly TaskCompletionSource _readyCompletionSource = new(); - private CancellationTokenProvider? _disconnectedTokenProvider; private CancellationTokenProvider? _closedTokenProvider; + private State? _state; private protected abstract Uri Uri { get; } @@ -68,8 +106,6 @@ private async void HandleConnecting() private async void HandleConnected() { - Interlocked.Exchange(ref _disconnectedTokenProvider, new())?.Cancel(); - OnConnected(); InvokeLog(LogMessage.Info("Connected")); await InvokeEventAsync(Connect).ConfigureAwait(false); @@ -77,9 +113,7 @@ private async void HandleConnected() private async void HandleDisconnected(WebSocketCloseStatus? closeStatus, string? description) { - Interlocked.Exchange(ref _disconnectedTokenProvider, null)?.Cancel(); - - InvokeLog(string.IsNullOrEmpty(description) ? LogMessage.Info("Disconnected") : LogMessage.Info("Disconnected", description.EndsWith('.') ? description[..^1] : description)); + InvokeLog(LogMessage.Info("Disconnected", string.IsNullOrEmpty(description) ? null : (description.EndsWith('.') ? description[..^1] : description))); var reconnect = Reconnect(closeStatus, description); var disconnectTask = InvokeEventAsync(Disconnect, reconnect); if (reconnect) @@ -92,8 +126,6 @@ private async void HandleDisconnected(WebSocketCloseStatus? closeStatus, string? private async void HandleClosed() { - Interlocked.Exchange(ref _disconnectedTokenProvider, null)?.Cancel(); - InvokeLog(LogMessage.Info("Closed")); var closeTask = InvokeEventAsync(Close).ConfigureAwait(false); @@ -138,9 +170,13 @@ private protected Task StartAsync(CancellationToken cancellationToken = default) return ConnectAsync(cancellationToken); } - private protected Task ConnectAsync(CancellationToken cancellationToken = default) + private protected async Task ConnectAsync(CancellationToken cancellationToken = default) { - return _webSocket.ConnectAsync(Uri, cancellationToken); + HandleConnecting(); + var connection = await _connectionProvider.CreateWebSocketConnectionAsync(Uri, cancellationToken).ConfigureAwait(false); + var state = _state = new(connection); + HandleConnected(); + state.StartReading(ReadAsync); } /// @@ -152,17 +188,82 @@ private protected Task ConnectAsync(CancellationToken cancellationToken = defaul /// public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, string? statusDescription = null, CancellationToken cancellationToken = default) { - var closedTokenProvider = Interlocked.Exchange(ref _closedTokenProvider, null) ?? throw new InvalidOperationException("Connection not started."); + //var closedTokenProvider = Interlocked.Exchange(ref _closedTokenProvider, null) ?? throw new InvalidOperationException("Connection not started."); + + //closedTokenProvider.Cancel(); + + var state = Interlocked.Exchange(ref _state, null); + + if (state is null || !state.TryIndicateDisconnecting()) + throw new InvalidOperationException("Connection not started."); + + var connection = state.Connection; + + try + { + await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); + } + catch + { + connection.Abort(); + HandleClosed(); + throw; + } - closedTokenProvider.Cancel(); + await state.ReadTask.ConfigureAwait(false); + HandleClosed(); + } + + private async Task ReadAsync(State state) + { + var connection = state.Connection; + var token = state.DisconnectedTokenProvider.Token; try { - await _webSocket.CloseAsync(status, statusDescription, cancellationToken).ConfigureAwait(false); + using RentedArrayBufferWriter writer = new(DefaultBufferSize); + while (true) + { + var result = await connection.ReceiveAsync(writer.GetMemory(), token).ConfigureAwait(false); + + if (result.EndOfMessage) + { + if (result.MessageType is WebSocketMessageType.Close) + break; + + writer.Advance(result.Count); + HandleMessageReceived(writer.WrittenMemory); + writer.Clear(); + } + else + writer.Advance(result.Count); + } } catch { } + + if (state.TryIndicateDisconnecting()) + { + _state = null; + state.Dispose(); + HandleDisconnected((WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); + } + } + + public void Abort() + { + var state = Interlocked.Exchange(ref _state, null); + + if (state is null) + return; + + var disconnecting = state.TryIndicateDisconnecting(); + + state.Connection.Abort(); + + if (disconnecting) + HandleClosed(); } private protected virtual void OnConnected() @@ -171,9 +272,14 @@ private protected virtual void OnConnected() private protected ValueTask AbortAndReconnectAsync() { + var state = Interlocked.Exchange(ref _state, null); + + if (state is null || !state.TryIndicateDisconnecting()) + return default; + try { - _webSocket.Abort(); + state.Connection.Abort(); } catch (Exception ex) { @@ -183,8 +289,57 @@ private protected ValueTask AbortAndReconnectAsync() return ReconnectAsync(); } - public ValueTask SendPayloadAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - => _webSocket.SendAsync(buffer, cancellationToken); + public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) + { + properties ??= _defaultPayloadProperties; + while (true) + { + var state = _state; + + if (state is null) + { + if (_closedTokenProvider is null) + throw new InvalidOperationException("Connection not started."); + + if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + { + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // + continue; + } + + throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting."); + } + + var result = await _rateLimiter.TryAcquireAsync().ConfigureAwait(false); + + if (result.RateLimited) + { + if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryRateLimit)) + { + await Task.Delay(result.ResetAfter, cancellationToken).ConfigureAwait(false); + continue; + } + + throw new InvalidOperationException("Rate limit triggered."); + } + + try + { + await state.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not ArgumentException) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + continue; + + throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting.", ex); + } + + return; + } + } private protected abstract bool Reconnect(WebSocketCloseStatus? status, string? description); @@ -231,7 +386,7 @@ private protected async ValueTask ReconnectAsync() private protected async void StartHeartbeating(double interval) { - if (_disconnectedTokenProvider is not { Token: var cancellationToken }) + if (_state is not { DisconnectedTokenProvider.Token: var cancellationToken }) return; PeriodicTimer timer; @@ -253,6 +408,7 @@ private protected async void StartHeartbeating(double interval) try { await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false); + Console.WriteLine("Sending heartbeat"); await HeartbeatAsync(cancellationToken).ConfigureAwait(false); } catch @@ -517,8 +673,8 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - _webSocket.Dispose(); - _disconnectedTokenProvider?.Dispose(); + _state?.Dispose(); + _rateLimiter.Dispose(); _closedTokenProvider?.Dispose(); } } diff --git a/NetCord/Gateway/WebSocketPayloadProperties.cs b/NetCord/Gateway/WebSocketPayloadProperties.cs new file mode 100644 index 000000000..56e06a03b --- /dev/null +++ b/NetCord/Gateway/WebSocketPayloadProperties.cs @@ -0,0 +1,10 @@ +using NetCord.Gateway.WebSockets; + +namespace NetCord.Gateway; + +public partial record WebSocketPayloadProperties +{ + public WebSocketMessageType MessageType { get; set; } + public WebSocketMessageFlags MessageFlags { get; set; } = WebSocketMessageFlags.EndOfMessage; + public WebSocketRetryHandling RetryHandling { get; set; } = WebSocketRetryHandling.Retry; +} diff --git a/NetCord/Gateway/WebSocketRetryHandling.cs b/NetCord/Gateway/WebSocketRetryHandling.cs new file mode 100644 index 000000000..283993e55 --- /dev/null +++ b/NetCord/Gateway/WebSocketRetryHandling.cs @@ -0,0 +1,10 @@ +namespace NetCord.Gateway; + +[Flags] +public enum WebSocketRetryHandling : byte +{ + NoRetry = 0, + RetryRateLimit = 1 << 0, + RetryReconnect = 1 << 1, + Retry = RetryRateLimit | RetryReconnect, +} diff --git a/NetCord/Gateway/WebSockets/IWebSocket.cs b/NetCord/Gateway/WebSockets/IWebSocket.cs deleted file mode 100644 index 91bb1b9c5..000000000 --- a/NetCord/Gateway/WebSockets/IWebSocket.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Net.WebSockets; - -namespace NetCord.Gateway.WebSockets; - -public interface IWebSocket : IDisposable -{ - public event Action? Connecting; - public event Action? Connected; - public event Action? Disconnected; - public event Action? Closed; - public event Action>? MessageReceived; - - /// - /// Connects to a WebSocket server. - /// - public Task ConnectAsync(Uri uri, CancellationToken cancellationToken = default); - - /// - /// Closes the . - /// - public Task CloseAsync(WebSocketCloseStatus status, string? statusDescription, CancellationToken cancellationToken = default); - - /// - /// Aborts the . - /// - public void Abort(); - - /// - /// Sends a message. - /// - public ValueTask SendAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); -} diff --git a/NetCord/Gateway/WebSockets/IWebSocketConnection.cs b/NetCord/Gateway/WebSockets/IWebSocketConnection.cs new file mode 100644 index 000000000..bc45fb6f5 --- /dev/null +++ b/NetCord/Gateway/WebSockets/IWebSocketConnection.cs @@ -0,0 +1,16 @@ +namespace NetCord.Gateway.WebSockets; + +public interface IWebSocketConnection : IDisposable +{ + public int? CloseStatus { get; } + + public string? CloseStatusDescription { get; } + + public ValueTask SendAsync(ReadOnlyMemory buffer, WebSocketMessageType messageType, WebSocketMessageFlags messageFlags, CancellationToken cancellationToken = default); + + public ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default); + + public ValueTask CloseAsync(int closeStatus, string? closeStatusDescription, CancellationToken cancellationToken = default); + + public void Abort(); +} diff --git a/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs b/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs new file mode 100644 index 000000000..05a7eb8da --- /dev/null +++ b/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs @@ -0,0 +1,6 @@ +namespace NetCord.Gateway.WebSockets; + +public interface IWebSocketConnectionProvider +{ + public ValueTask CreateWebSocketConnectionAsync(Uri uri, CancellationToken cancellationToken = default); +} diff --git a/NetCord/Gateway/WebSockets/WebSocket.cs b/NetCord/Gateway/WebSockets/WebSocket.cs deleted file mode 100644 index b03e72691..000000000 --- a/NetCord/Gateway/WebSockets/WebSocket.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.WebSockets; - -namespace NetCord.Gateway.WebSockets; - -public sealed class WebSocket : IWebSocket -{ - private const int DefaultBufferSize = 8192; - - private State? _state; - private bool _disposed; - - public event Action? Connecting; - public event Action? Connected; - public event Action? Disconnected; - public event Action? Closed; - public event Action>? MessageReceived; - - public async Task ConnectAsync(Uri uri, CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(_disposed, typeof(WebSocket)); - - State newState = new(); - var state = Interlocked.CompareExchange(ref _state, newState, null); - if (state is not null) - { - newState.Dispose(); - ThrowAlreadyConnectingOrConnected(); - } - - InvokeEvent(Connecting); - - try - { - await newState.WebSocket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); - } - catch - { - Interlocked.Exchange(ref _state, null)?.Dispose(); - throw; - } - - InvokeEvent(Connected); - - newState.StartReading(ReadAsync); - } - - public async Task CloseAsync(WebSocketCloseStatus status, string? statusDescription, CancellationToken cancellationToken = default) - { - var state = Interlocked.Exchange(ref _state, null); - - if (state is null || !state.TryIndicateDisconnecting()) - ThrowNotConnected(); - - var webSocket = state.WebSocket; - - try - { - await webSocket.CloseOutputAsync(status, statusDescription, cancellationToken).ConfigureAwait(false); - } - catch - { - webSocket.Abort(); - InvokeEvent(Closed); - throw; - } - - await state.ReadTask.ConfigureAwait(false); - - InvokeEvent(Closed); - } - - public void Abort() - { - var state = Interlocked.Exchange(ref _state, null); - - if (state is null) - return; - - var disconnecting = state.TryIndicateDisconnecting(); - - state.WebSocket.Abort(); - - if (disconnecting) - InvokeEvent(Closed); - } - - public ValueTask SendAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - var state = _state; - - if (state is null) - ThrowNotConnected(); - - return state.WebSocket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); - } - - private async Task ReadAsync(State state) - { - var webSocket = state.WebSocket; - try - { - using RentedArrayBufferWriter writer = new(DefaultBufferSize); - while (true) - { - var result = await webSocket.ReceiveAsync(writer.GetMemory(), default).ConfigureAwait(false); - - if (result.EndOfMessage) - { - if (result.MessageType is WebSocketMessageType.Close) - break; - - writer.Advance(result.Count); - InvokeEvent(MessageReceived, writer.WrittenMemory); - writer.Clear(); - } - else - writer.Advance(result.Count); - } - } - catch - { - } - - if (state.TryIndicateDisconnecting()) - { - _state = null; - state.Dispose(); - InvokeEvent(Disconnected, webSocket.CloseStatus, webSocket.CloseStatusDescription); - } - } - - private static void InvokeEvent(Action? action) - { - if (action is not null) - { - try - { - action(); - } - catch - { - } - } - } - - private static void InvokeEvent(Action? action, WebSocketCloseStatus? status, string? description) - { - if (action is not null) - { - try - { - action(status, description); - } - catch - { - } - } - } - - private static void InvokeEvent(Action>? action, ReadOnlyMemory buffer) - { - if (action is not null) - { - try - { - action(buffer); - } - catch - { - } - } - } - - public void Dispose() - { - _state?.Dispose(); - _disposed = true; - } - - [DoesNotReturn] - private static void ThrowAlreadyConnectingOrConnected() - { - throw new InvalidOperationException("The WebSocket is already connecting or connected."); - } - - [DoesNotReturn] - private static void ThrowNotConnected() - { - throw new InvalidOperationException("The WebSocket is not connected."); - } - - private sealed class State : IDisposable - { - public ClientWebSocket WebSocket { get; } = new(); - - public Task ReadTask => _readCompletionSource.Task; - - public TaskCompletionSource _readCompletionSource = new(); - - private int _state; - - public async void StartReading(Func readAsync) - { - await readAsync(this).ConfigureAwait(false); - - _readCompletionSource.TrySetResult(); - } - - public bool TryIndicateDisconnecting() - { - return Interlocked.Exchange(ref _state, 1) is 0; - } - - public void Dispose() - { - WebSocket.Dispose(); - } - } -} diff --git a/NetCord/Gateway/WebSockets/WebSocketConnection.cs b/NetCord/Gateway/WebSockets/WebSocketConnection.cs new file mode 100644 index 000000000..4dfffcc0c --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketConnection.cs @@ -0,0 +1,50 @@ +using System.Net.WebSockets; + +namespace NetCord.Gateway.WebSockets; + +internal sealed class WebSocketConnection : IWebSocketConnection +{ + private readonly ClientWebSocket _webSocket; + + public static async ValueTask CreateAsync(Uri uri, CancellationToken cancellationToken = default) + { + ClientWebSocket webSocket = new(); + await webSocket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); + return new WebSocketConnection(webSocket); + } + + private WebSocketConnection(ClientWebSocket webSocket) + { + _webSocket = webSocket; + } + + public int? CloseStatus => (int?)_webSocket.CloseStatus; + + public string? CloseStatusDescription => _webSocket.CloseStatusDescription; + + public void Abort() + { + _webSocket.Abort(); + } + + public ValueTask CloseAsync(int closeStatus, string? closeStatusDescription, CancellationToken cancellationToken = default) + { + return new(_webSocket.CloseOutputAsync((WebSocketCloseStatus)closeStatus, closeStatusDescription, cancellationToken)); + } + + public async ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var result = await _webSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + return new(result.Count, (WebSocketMessageType)result.MessageType, result.EndOfMessage); + } + + public ValueTask SendAsync(ReadOnlyMemory buffer, WebSocketMessageType messageType, WebSocketMessageFlags messageFlags, CancellationToken cancellationToken = default) + { + return _webSocket.SendAsync(buffer, (System.Net.WebSockets.WebSocketMessageType)messageType, (System.Net.WebSockets.WebSocketMessageFlags)messageFlags, cancellationToken); + } + + public void Dispose() + { + _webSocket.Dispose(); + } +} diff --git a/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs b/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs new file mode 100644 index 000000000..4a9a12ae1 --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs @@ -0,0 +1,9 @@ +namespace NetCord.Gateway.WebSockets; + +public class WebSocketConnectionProvider : IWebSocketConnectionProvider +{ + public ValueTask CreateWebSocketConnectionAsync(Uri uri, CancellationToken cancellationToken = default) + { + return WebSocketConnection.CreateAsync(uri, cancellationToken); + } +} diff --git a/NetCord/Gateway/WebSockets/WebSocketConnectionReceiveResult.cs b/NetCord/Gateway/WebSockets/WebSocketConnectionReceiveResult.cs new file mode 100644 index 000000000..cc097e235 --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketConnectionReceiveResult.cs @@ -0,0 +1,27 @@ +namespace NetCord.Gateway.WebSockets; + +#pragma warning disable IDE0032 // Use auto property + +// Adopted from System.Net.WebSockets.ValueWebSocketReceiveResult + +public readonly struct WebSocketConnectionReceiveResult +{ + private readonly uint _countAndEndOfMessage; + private readonly WebSocketMessageType _messageType; + + public WebSocketConnectionReceiveResult(int count, WebSocketMessageType messageType, bool endOfMessage) + { + ArgumentOutOfRangeException.ThrowIfNegative(count, nameof(count)); + if ((uint)messageType > (uint)WebSocketMessageType.Close) + ThrowMessageTypeOutOfRange(); + + _countAndEndOfMessage = (uint)count | (uint)(endOfMessage ? 1 << 31 : 0); + _messageType = messageType; + + static void ThrowMessageTypeOutOfRange() => throw new ArgumentOutOfRangeException(nameof(messageType)); + } + + public int Count => (int)(_countAndEndOfMessage & 0x7FFFFFFF); + public bool EndOfMessage => (_countAndEndOfMessage & 0x80000000) == 0x80000000; + public WebSocketMessageType MessageType => _messageType; +} diff --git a/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs new file mode 100644 index 000000000..eb00d1303 --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs @@ -0,0 +1,8 @@ +namespace NetCord.Gateway.WebSockets; + +public enum WebSocketMessageFlags : byte +{ + None = 0, + EndOfMessage = 1, + DisableCompression = 2, +} diff --git a/NetCord/Gateway/WebSockets/WebSocketMessageType.cs b/NetCord/Gateway/WebSockets/WebSocketMessageType.cs new file mode 100644 index 000000000..890c77e6b --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketMessageType.cs @@ -0,0 +1,8 @@ +namespace NetCord.Gateway.WebSockets; + +public enum WebSocketMessageType : byte +{ + Text = 0, + Binary = 1, + Close = 2, +} diff --git a/NetCord/Rest/RateLimitedException.cs b/NetCord/Rest/RateLimitedException.cs index bf7719a99..540862c70 100644 --- a/NetCord/Rest/RateLimitedException.cs +++ b/NetCord/Rest/RateLimitedException.cs @@ -1,4 +1,4 @@ -namespace NetCord.Rest.RateLimits; +namespace NetCord.Rest; public class RateLimitedException(long reset, RateLimitScope scope) : Exception("Rate limit triggered.") { diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index b91155d80..d8930f907 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using NetCord.Gateway; +using NetCord.Gateway.Compression; using NetCord.JsonModels; using NetCord.Rest; using NetCord.Services; @@ -20,6 +21,7 @@ internal static class Program { Intents = GatewayIntents.All, ConnectionProperties = ConnectionPropertiesProperties.IOS, + Compression = new ZLibGatewayCompression(), }); private static readonly CommandService _commandService = new(); @@ -89,15 +91,25 @@ private static async Task Main() await _client.StartAsync(); await _client.ReadyAsync; - try + //try + //{ + // await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); + //} + //catch (RestException ex) + //{ + // var error = ex.Error; + // Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); + //} + + for (int i = 0; i < 120; i++) { - await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); - } - catch (RestException ex) - { - var error = ex.Error; - Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); + await _client.UpdatePresenceAsync(new(UserStatusType.Online) + { + Activities = [new($"wzium {i}", UserActivityType.Game)], + }); + Console.WriteLine(i); } + await Task.Delay(-1); } From 93d40ecedc0075f78a1b0aca81296a7feadcc3a7 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Sun, 25 Aug 2024 22:23:50 +0200 Subject: [PATCH 02/33] Implement more --- NetCord/Gateway/GatewayClient.cs | 38 +- NetCord/Gateway/GatewayClientConfiguration.cs | 4 +- NetCord/Gateway/GatewayRateLimiter.cs | 45 -- NetCord/Gateway/GatewayRateLimiterProvider.cs | 41 ++ NetCord/Gateway/IRateLimiter.cs | 2 - NetCord/Gateway/IRateLimiterProvider.cs | 6 + .../Gateway/IWebSocketClientConfiguration.cs | 2 +- NetCord/Gateway/NullRateLimiter.cs | 6 +- NetCord/Gateway/NullRateLimiterProvider.cs | 8 + NetCord/Gateway/RateLimitAcquisitionResult.cs | 2 +- NetCord/Gateway/ShardedGatewayClient.cs | 2 +- .../ShardedGatewayClientConfiguration.cs | 2 +- NetCord/Gateway/Voice/VoiceClient.cs | 19 +- .../Gateway/Voice/VoiceClientConfiguration.cs | 2 +- NetCord/Gateway/WebSocketClient.cs | 401 ++++++++++++++---- .../WebSockets/IWebSocketConnection.cs | 2 + .../IWebSocketConnectionProvider.cs | 2 +- .../Gateway/WebSockets/WebSocketConnection.cs | 19 +- .../WebSockets/WebSocketConnectionProvider.cs | 4 +- .../WebSockets/WebSocketMessageFlags.cs | 6 +- NetCord/Rest/RateLimits/GlobalRateLimiter.cs | 2 +- .../RateLimits/NoRateLimitRouteRateLimiter.cs | 2 +- .../RateLimits/RateLimitAcquisitionResult.cs | 4 +- NetCord/Rest/RateLimits/RouteRateLimiter.cs | 2 +- .../RateLimits/UnknownRouteRateLimiter.cs | 4 +- Tests/NetCord.Test/Program.cs | 25 +- 26 files changed, 443 insertions(+), 209 deletions(-) delete mode 100644 NetCord/Gateway/GatewayRateLimiter.cs create mode 100644 NetCord/Gateway/GatewayRateLimiterProvider.cs create mode 100644 NetCord/Gateway/IRateLimiterProvider.cs create mode 100644 NetCord/Gateway/NullRateLimiterProvider.cs diff --git a/NetCord/Gateway/GatewayClient.cs b/NetCord/Gateway/GatewayClient.cs index 19ccadd19..6cd367ff6 100644 --- a/NetCord/Gateway/GatewayClient.cs +++ b/NetCord/Gateway/GatewayClient.cs @@ -836,7 +836,7 @@ private protected override void OnConnected() _compression.Initialize(); } - private ValueTask SendIdentifyAsync(PresenceProperties? presence = null, CancellationToken cancellationToken = default) + private ValueTask SendIdentifyAsync(ConnectionState connectionState, PresenceProperties? presence = null, CancellationToken cancellationToken = default) { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Identify, new(Token.RawToken) { @@ -847,7 +847,7 @@ private ValueTask SendIdentifyAsync(PresenceProperties? presence = null, Cancell Intents = _configuration.Intents, }).Serialize(Serialization.Default.GatewayPayloadPropertiesGatewayIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } /// @@ -858,8 +858,8 @@ private ValueTask SendIdentifyAsync(PresenceProperties? presence = null, Cancell /// public async Task StartAsync(PresenceProperties? presence = null, CancellationToken cancellationToken = default) { - await StartAsync(cancellationToken).ConfigureAwait(false); - await SendIdentifyAsync(presence, cancellationToken).ConfigureAwait(false); + var connectionState = await StartAsync(cancellationToken).ConfigureAwait(false); + await SendIdentifyAsync(connectionState, presence, cancellationToken).ConfigureAwait(false); } /// @@ -871,35 +871,35 @@ public async Task StartAsync(PresenceProperties? presence = null, CancellationTo /// public async Task ResumeAsync(string sessionId, int sequenceNumber, CancellationToken cancellationToken = default) { - await ConnectAsync(cancellationToken).ConfigureAwait(false); - await TryResumeAsync(SessionId = sessionId, SequenceNumber = sequenceNumber, cancellationToken).ConfigureAwait(false); + var connectionState = await StartAsync(cancellationToken).ConfigureAwait(false); + await TryResumeAsync(connectionState, SessionId = sessionId, SequenceNumber = sequenceNumber, cancellationToken).ConfigureAwait(false); } private protected override bool Reconnect(WebSocketCloseStatus? status, string? description) => status is not ((WebSocketCloseStatus)4004 or (WebSocketCloseStatus)4010 or (WebSocketCloseStatus)4011 or (WebSocketCloseStatus)4012 or (WebSocketCloseStatus)4013 or (WebSocketCloseStatus)4014); - private protected override ValueTask TryResumeAsync(CancellationToken cancellationToken = default) + private protected override ValueTask TryResumeAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) { - return TryResumeAsync(SessionId!, SequenceNumber, cancellationToken); + return TryResumeAsync(connectionState, SessionId!, SequenceNumber, cancellationToken); } - private ValueTask TryResumeAsync(string sessionId, int sequenceNumber, CancellationToken cancellationToken = default) + private ValueTask TryResumeAsync(ConnectionState connectionState, string sessionId, int sequenceNumber, CancellationToken cancellationToken = default) { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Resume, new(Token.RawToken, sessionId, sequenceNumber)).Serialize(Serialization.Default.GatewayPayloadPropertiesGatewayResumeProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } - private protected override ValueTask HeartbeatAsync(CancellationToken cancellationToken = default) + private protected override ValueTask HeartbeatAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Heartbeat, SequenceNumber).Serialize(Serialization.Default.GatewayPayloadPropertiesInt32); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } private protected override JsonPayload CreatePayload(ReadOnlyMemory payload) => JsonSerializer.Deserialize(_compression.Decompress(payload).Span, Serialization.Default.JsonPayload)!; - private protected override async Task ProcessPayloadAsync(JsonPayload payload) + private protected override async Task ProcessPayloadAsync(State state, JsonPayload payload) { switch ((GatewayOpcode)payload.Opcode) { @@ -907,7 +907,7 @@ private protected override async Task ProcessPayloadAsync(JsonPayload payload) SequenceNumber = payload.SequenceNumber.GetValueOrDefault(); try { - await ProcessEventAsync(payload).ConfigureAwait(false); + await ProcessEventAsync(state, payload).ConfigureAwait(false); } catch (Exception ex) { @@ -918,13 +918,13 @@ private protected override async Task ProcessPayloadAsync(JsonPayload payload) break; case GatewayOpcode.Reconnect: InvokeLog(LogMessage.Info("Reconnect request")); - await AbortAndReconnectAsync().ConfigureAwait(false); + await AbortAndReconnectAsync(state).ConfigureAwait(false); break; case GatewayOpcode.InvalidSession: InvokeLog(LogMessage.Info("Invalid session")); try { - await SendIdentifyAsync().ConfigureAwait(false); + await SendIdentifyAsync(state.ConnectionState!).ConfigureAwait(false); } catch (Exception ex) { @@ -932,7 +932,7 @@ private protected override async Task ProcessPayloadAsync(JsonPayload payload) } break; case GatewayOpcode.Hello: - StartHeartbeating(payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); + StartHeartbeating(state.ConnectionState!, payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); break; case GatewayOpcode.HeartbeatACK: await UpdateLatencyAsync(_latencyTimer.Elapsed).ConfigureAwait(false); @@ -970,7 +970,7 @@ public ValueTask RequestGuildUsersAsync(GuildUsersRequestProperties requestPrope return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesGuildUsersRequestProperties), properties, cancellationToken); } - private async Task ProcessEventAsync(JsonPayload payload) + private async Task ProcessEventAsync(State state, JsonPayload payload) { var data = payload.Data.GetValueOrDefault(); var name = payload.Event!; @@ -992,6 +992,7 @@ await InvokeEventAsync(Ready, args, data => SessionId = args.SessionId; ApplicationFlags = args.ApplicationFlags; + state.IndicateReady(state.ConnectionState!); _readyCompletionSource.TrySetResult(); }).ConfigureAwait(false); await updateLatencyTask.ConfigureAwait(false); @@ -1004,6 +1005,7 @@ await InvokeEventAsync(Ready, args, data => var updateLatencyTask = UpdateLatencyAsync(latency); var resumeTask = InvokeResumeEventAsync(); + state.IndicateReady(state.ConnectionState!); _readyCompletionSource.TrySetResult(); await updateLatencyTask.ConfigureAwait(false); diff --git a/NetCord/Gateway/GatewayClientConfiguration.cs b/NetCord/Gateway/GatewayClientConfiguration.cs index 7edba9551..b9535e00c 100644 --- a/NetCord/Gateway/GatewayClientConfiguration.cs +++ b/NetCord/Gateway/GatewayClientConfiguration.cs @@ -8,7 +8,7 @@ namespace NetCord.Gateway; public class GatewayClientConfiguration : IWebSocketClientConfiguration { public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; init; } - public IRateLimiter? RateLimiter { get; init; } + public IRateLimiterProvider? RateLimiterProvider { get; init; } public WebSocketPayloadProperties? DefaultPayloadProperties { get; init; } public IReconnectStrategy? ReconnectStrategy { get; init; } public ILatencyTimer? LatencyTimer { get; init; } @@ -24,5 +24,5 @@ public class GatewayClientConfiguration : IWebSocketClientConfiguration public bool CacheDMChannels { get; init; } = true; public Rest.RestClientConfiguration? RestClientConfiguration { get; init; } - IRateLimiter? IWebSocketClientConfiguration.RateLimiter => RateLimiter is { } rateLimiter ? rateLimiter : new GatewayRateLimiter(120, 60_000); + IRateLimiterProvider? IWebSocketClientConfiguration.RateLimiterProvider => RateLimiterProvider is { } rateLimiter ? rateLimiter : new GatewayRateLimiterProvider(120, 60_000); } diff --git a/NetCord/Gateway/GatewayRateLimiter.cs b/NetCord/Gateway/GatewayRateLimiter.cs deleted file mode 100644 index 433ae1bee..000000000 --- a/NetCord/Gateway/GatewayRateLimiter.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace NetCord.Gateway; - -public sealed class GatewayRateLimiter(int limit, long duration) : IRateLimiter -{ - private readonly object _lock = new(); - private readonly int _limit = limit; - private int _remaining = limit; - private long _reset; - - public ValueTask TryAcquireAsync() - { - var timestamp = Environment.TickCount64; - lock (_lock) - { - var diff = _reset - timestamp; - if (diff <= 0) - { - _remaining = _limit - 1; - _reset = timestamp + duration; - } - else - { - if (_remaining == 0) - return new(RateLimitAcquisitionResult.RateLimit((int)diff)); - else - _remaining--; - } - } - - return new(RateLimitAcquisitionResult.NoRateLimit()); - } - - public void Reset() - { - lock (_lock) - { - _remaining = _limit; - _reset = 0; - } - } - - public void Dispose() - { - } -} diff --git a/NetCord/Gateway/GatewayRateLimiterProvider.cs b/NetCord/Gateway/GatewayRateLimiterProvider.cs new file mode 100644 index 000000000..6463b566e --- /dev/null +++ b/NetCord/Gateway/GatewayRateLimiterProvider.cs @@ -0,0 +1,41 @@ +namespace NetCord.Gateway; + +public class GatewayRateLimiterProvider(int limit, long duration) : IRateLimiterProvider +{ + public IRateLimiter CreateRateLimiter() => new GatewayRateLimiter(limit, duration); + + private sealed class GatewayRateLimiter(int limit, long duration) : IRateLimiter + { + private readonly object _lock = new(); + private readonly int _limit = limit; + private int _remaining = limit; + private long _reset; + + public ValueTask TryAcquireAsync() + { + var timestamp = Environment.TickCount64; + lock (_lock) + { + var diff = _reset - timestamp; + if (diff <= 0) + { + _remaining = _limit - 1; + _reset = timestamp + duration; + } + else + { + if (_remaining == 0) + return new(RateLimitAcquisitionResult.RateLimit((int)diff)); + else + _remaining--; + } + } + + return new(RateLimitAcquisitionResult.NoRateLimit); + } + + public void Dispose() + { + } + } +} diff --git a/NetCord/Gateway/IRateLimiter.cs b/NetCord/Gateway/IRateLimiter.cs index c7a25ace4..7dd7d0ce4 100644 --- a/NetCord/Gateway/IRateLimiter.cs +++ b/NetCord/Gateway/IRateLimiter.cs @@ -3,6 +3,4 @@ public interface IRateLimiter : IDisposable { public ValueTask TryAcquireAsync(); - - public void Reset(); } diff --git a/NetCord/Gateway/IRateLimiterProvider.cs b/NetCord/Gateway/IRateLimiterProvider.cs new file mode 100644 index 000000000..885da5e78 --- /dev/null +++ b/NetCord/Gateway/IRateLimiterProvider.cs @@ -0,0 +1,6 @@ +namespace NetCord.Gateway; + +public interface IRateLimiterProvider +{ + public IRateLimiter CreateRateLimiter(); +} diff --git a/NetCord/Gateway/IWebSocketClientConfiguration.cs b/NetCord/Gateway/IWebSocketClientConfiguration.cs index 131f763e3..392912f0a 100644 --- a/NetCord/Gateway/IWebSocketClientConfiguration.cs +++ b/NetCord/Gateway/IWebSocketClientConfiguration.cs @@ -9,6 +9,6 @@ internal interface IWebSocketClientConfiguration public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; } public IReconnectStrategy? ReconnectStrategy { get; } public ILatencyTimer? LatencyTimer { get; } - public IRateLimiter? RateLimiter { get; } + public IRateLimiterProvider? RateLimiterProvider { get; } public WebSocketPayloadProperties? DefaultPayloadProperties { get; } } diff --git a/NetCord/Gateway/NullRateLimiter.cs b/NetCord/Gateway/NullRateLimiter.cs index f03ad4929..5778f2b6b 100644 --- a/NetCord/Gateway/NullRateLimiter.cs +++ b/NetCord/Gateway/NullRateLimiter.cs @@ -8,11 +8,7 @@ private NullRateLimiter() { } - public ValueTask TryAcquireAsync() => new(RateLimitAcquisitionResult.NoRateLimit()); - - public void Reset() - { - } + public ValueTask TryAcquireAsync() => new(RateLimitAcquisitionResult.NoRateLimit); public void Dispose() { diff --git a/NetCord/Gateway/NullRateLimiterProvider.cs b/NetCord/Gateway/NullRateLimiterProvider.cs new file mode 100644 index 000000000..2a545e56c --- /dev/null +++ b/NetCord/Gateway/NullRateLimiterProvider.cs @@ -0,0 +1,8 @@ +namespace NetCord.Gateway; + +internal class NullRateLimiterProvider : IRateLimiterProvider +{ + public static NullRateLimiterProvider Instance { get; } = new(); + + public IRateLimiter CreateRateLimiter() => NullRateLimiter.Instance; +} diff --git a/NetCord/Gateway/RateLimitAcquisitionResult.cs b/NetCord/Gateway/RateLimitAcquisitionResult.cs index f3c97a851..d87e286ab 100644 --- a/NetCord/Gateway/RateLimitAcquisitionResult.cs +++ b/NetCord/Gateway/RateLimitAcquisitionResult.cs @@ -8,7 +8,7 @@ private RateLimitAcquisitionResult(int resetAfter, bool rateLimited) RateLimited = rateLimited; } - public static RateLimitAcquisitionResult NoRateLimit() => new(0, false); + public static RateLimitAcquisitionResult NoRateLimit { get; } = new(0, false); public static RateLimitAcquisitionResult RateLimit(int resetAfter) => new(resetAfter, true); diff --git a/NetCord/Gateway/ShardedGatewayClient.cs b/NetCord/Gateway/ShardedGatewayClient.cs index 7add3bfde..e06cca89d 100644 --- a/NetCord/Gateway/ShardedGatewayClient.cs +++ b/NetCord/Gateway/ShardedGatewayClient.cs @@ -220,7 +220,7 @@ private GatewayClientConfiguration GetGatewayClientConfiguration(Shard shard) return new() { WebSocketConnectionProvider = configuration.WebSocketConnectionProviderFactory!(shard), - RateLimiter = configuration.RateLimiterFactory!(shard), + RateLimiterProvider = configuration.RateLimiterProviderFactory!(shard), DefaultPayloadProperties = configuration.DefaultPayloadPropertiesFactory!(shard), ReconnectStrategy = configuration.ReconnectStrategyFactory!(shard), LatencyTimer = configuration.LatencyTimerFactory!(shard), diff --git a/NetCord/Gateway/ShardedGatewayClientConfiguration.cs b/NetCord/Gateway/ShardedGatewayClientConfiguration.cs index 24718ee9c..d6a70c26c 100644 --- a/NetCord/Gateway/ShardedGatewayClientConfiguration.cs +++ b/NetCord/Gateway/ShardedGatewayClientConfiguration.cs @@ -8,7 +8,7 @@ namespace NetCord.Gateway; public class ShardedGatewayClientConfiguration { public Func? WebSocketConnectionProviderFactory { get; init; } - public Func? RateLimiterFactory { get; init; } + public Func? RateLimiterProviderFactory { get; init; } public Func? DefaultPayloadPropertiesFactory { get; init; } public Func? ReconnectStrategyFactory { get; init; } public Func? LatencyTimerFactory { get; init; } diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index c42cfb6e4..d4c9a760b 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -56,7 +56,7 @@ private ValueTask SendIdentifyAsync(CancellationToken cancellationToken = defaul { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Identify, new(GuildId, UserId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendPayloadAsync(serializedPayload, _internalPayloadProperties, cancellationToken); } /// @@ -75,28 +75,28 @@ private ValueTask SendIdentifyAsync(CancellationToken cancellationToken = defaul /// public async Task ResumeAsync(CancellationToken cancellationToken = default) { - await ConnectAsync(cancellationToken).ConfigureAwait(false); - await TryResumeAsync(cancellationToken).ConfigureAwait(false); + var connectionState = await base.StartAsync(cancellationToken).ConfigureAwait(false); + await TryResumeAsync(connectionState, cancellationToken).ConfigureAwait(false); } private protected override bool Reconnect(WebSocketCloseStatus? status, string? description) => status is not ((WebSocketCloseStatus)4004 or (WebSocketCloseStatus)4006 or (WebSocketCloseStatus)4009 or (WebSocketCloseStatus)4014); - private protected override ValueTask TryResumeAsync(CancellationToken cancellationToken = default) + private protected override ValueTask TryResumeAsync(ConnectionState state, CancellationToken cancellationToken = default) { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Resume, new(GuildId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceResumeProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(state, serializedPayload, _internalPayloadProperties, cancellationToken); } - private protected override ValueTask HeartbeatAsync(CancellationToken cancellationToken = default) + private protected override ValueTask HeartbeatAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Heartbeat, Environment.TickCount).Serialize(Serialization.Default.VoicePayloadPropertiesInt32); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } - private protected override async Task ProcessPayloadAsync(JsonPayload payload) + private protected override async Task ProcessPayloadAsync(State state, JsonPayload payload) { switch ((VoiceOpcode)payload.Opcode) { @@ -163,6 +163,7 @@ void GetIpAndPort(out string ip, out ushort port) InvokeLog(LogMessage.Info("Ready")); var readyTask = InvokeEventAsync(Ready); + state.IndicateReady(state.ConnectionState!); _readyCompletionSource.TrySetResult(); await readyTask.ConfigureAwait(false); @@ -189,7 +190,7 @@ void GetIpAndPort(out string ip, out ushort port) break; case VoiceOpcode.Hello: { - StartHeartbeating(payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); + StartHeartbeating(state.ConnectionState!, payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); } break; case VoiceOpcode.Resumed: diff --git a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs index 5d581f42b..8c1ff16ea 100644 --- a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs +++ b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs @@ -18,5 +18,5 @@ public class VoiceClientConfiguration : IWebSocketClientConfiguration public IVoiceEncryption? Encryption { get; init; } public bool RedirectInputStreams { get; init; } - IRateLimiter? IWebSocketClientConfiguration.RateLimiter => null; + IRateLimiterProvider? IWebSocketClientConfiguration.RateLimiterProvider => null; } diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 9cd1bebca..4d2e03dbb 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text.Json; using NetCord.Gateway.JsonModels; @@ -12,27 +13,30 @@ namespace NetCord.Gateway; public abstract class WebSocketClient : IDisposable { - private sealed class State(IWebSocketConnection connection) : IDisposable + private protected sealed class ConnectionState(IWebSocketConnection connection, IRateLimiter rateLimiter) : IDisposable { - public IWebSocketConnection Connection { get; } = connection; + public IWebSocketConnection Connection => connection; + + public IRateLimiter RateLimiter => rateLimiter; public CancellationTokenProvider DisconnectedTokenProvider { get; } = new(); public Task ReadTask => _readCompletionSource.Task; - public TaskCompletionSource _readCompletionSource = new(); - - public Task ReadyTask => _readCompletionSource.Task; - - public TaskCompletionSource _readyCompletionSource = new(); + private readonly TaskCompletionSource _readCompletionSource = new(); private int _state; - public async void StartReading(Func readAsync) + public async void StartReading(State state, Func readAsync) { - await readAsync(this).ConfigureAwait(false); - - _readCompletionSource.TrySetResult(); + try + { + await readAsync(state).ConfigureAwait(false); + } + finally + { + _readCompletionSource.TrySetResult(); + } } public bool TryIndicateDisconnecting() @@ -48,10 +52,94 @@ public bool TryIndicateDisconnecting() public void Dispose() { DisconnectedTokenProvider.Dispose(); + RateLimiter.Dispose(); Connection.Dispose(); } } + private protected sealed class State : IDisposable + { + private ConnectionState? _connectionState; + + public ConnectionState? ConnectionState => _connectionState; + + public CancellationTokenProvider ClosedTokenProvider { get; } = new(); + + public Task ReadyTask => _readyCompletionSource.Task; + + private TaskCompletionSource _readyCompletionSource = new(); + + public Task ConnectedTask => _connectedCompletionSource.Task; + + private TaskCompletionSource _connectedCompletionSource = new(); + + public void IndicateConnected(ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + if (_connectionState != connectionState) + return; + + _connectedCompletionSource.TrySetResult(connectionState); + } + } + + public void IndicateReady(ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + if (_connectionState != connectionState) + return; + + _readyCompletionSource.TrySetResult(connectionState); + } + } + + public bool TryIndicateConnecting(ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + var previousState = _connectionState; + if (previousState is not null) + return false; + + _connectionState = connectionState; + } + + return true; + } + + public bool TryIndicateDisconnecting([MaybeNullWhen(false)] out ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + var previousState = _connectionState; + if (previousState is null || !previousState.TryIndicateDisconnecting()) + { + connectionState = null; + return false; + } + + _connectionState = null; + connectionState = previousState; + + _readyCompletionSource.TrySetCanceled(); + _readyCompletionSource = new(); + + _connectedCompletionSource.TrySetCanceled(); + _connectedCompletionSource = new(); + } + + return true; + } + + public void Dispose() + { + _connectionState?.Dispose(); + ClosedTokenProvider.Dispose(); + } + } + private const int DefaultBufferSize = 8192; private protected WebSocketClient(IWebSocketClientConfiguration configuration) @@ -59,20 +147,25 @@ private protected WebSocketClient(IWebSocketClientConfiguration configuration) _connectionProvider = configuration.WebSocketConnectionProvider ?? new WebSocketConnectionProvider(); _reconnectStrategy = configuration.ReconnectStrategy ?? new ReconnectStrategy(); _latencyTimer = configuration.LatencyTimer ?? new LatencyTimer(); - _rateLimiter = configuration.RateLimiter ?? NullRateLimiter.Instance; + _rateLimiterProvider = configuration.RateLimiterProvider ?? NullRateLimiterProvider.Instance; _defaultPayloadProperties = configuration.DefaultPayloadProperties is { } defaultPayloadProperties ? defaultPayloadProperties with { } : new(); } + private protected static readonly WebSocketPayloadProperties _internalPayloadProperties = new() + { + MessageFlags = WebSocketMessageFlags.EndOfMessage | WebSocketMessageFlags.BypassReady, + RetryHandling = WebSocketRetryHandling.RetryRateLimit, + }; + private readonly object _eventsLock = new(); private readonly IWebSocketConnectionProvider _connectionProvider; private readonly IReconnectStrategy _reconnectStrategy; - private readonly IRateLimiter _rateLimiter; + private readonly IRateLimiterProvider _rateLimiterProvider; private readonly WebSocketPayloadProperties _defaultPayloadProperties; private protected readonly ILatencyTimer _latencyTimer; private protected readonly TaskCompletionSource _readyCompletionSource = new(); - private CancellationTokenProvider? _closedTokenProvider; private State? _state; private protected abstract Uri Uri { get; } @@ -104,22 +197,26 @@ private async void HandleConnecting() await InvokeEventAsync(Connecting).ConfigureAwait(false); } - private async void HandleConnected() + private async void HandleConnected(State state) { OnConnected(); + state.IndicateConnected(state.ConnectionState!); InvokeLog(LogMessage.Info("Connected")); await InvokeEventAsync(Connect).ConfigureAwait(false); } - private async void HandleDisconnected(WebSocketCloseStatus? closeStatus, string? description) + private async void HandleDisconnected(State state, WebSocketCloseStatus? closeStatus, string? description) { InvokeLog(LogMessage.Info("Disconnected", string.IsNullOrEmpty(description) ? null : (description.EndsWith('.') ? description[..^1] : description))); var reconnect = Reconnect(closeStatus, description); var disconnectTask = InvokeEventAsync(Disconnect, reconnect); if (reconnect) - await ReconnectAsync().ConfigureAwait(false); + await ReconnectAsync(state).ConfigureAwait(false); else + { + _state = null; _readyCompletionSource.TrySetCanceled(); + } await disconnectTask.ConfigureAwait(false); } @@ -134,7 +231,7 @@ private async void HandleClosed() await closeTask; } - private async void HandleMessageReceived(ReadOnlyMemory data) + private async void HandleMessageReceived(State state, ReadOnlyMemory data) { try { @@ -146,11 +243,11 @@ private async void HandleMessageReceived(ReadOnlyMemory data) catch (Exception ex) { InvokeLog(LogMessage.Error(ex)); - await AbortAndReconnectAsync().ConfigureAwait(false); + await AbortAndReconnectAsync(state).ConfigureAwait(false); return; } - await ProcessPayloadAsync(payload).ConfigureAwait(false); + await ProcessPayloadAsync(state, payload).ConfigureAwait(false); } catch (Exception ex) { @@ -158,25 +255,52 @@ private async void HandleMessageReceived(ReadOnlyMemory data) } } - private protected Task StartAsync(CancellationToken cancellationToken = default) + private protected Task StartAsync(CancellationToken cancellationToken = default) { - CancellationTokenProvider newTokenProvider = new(); - if (Interlocked.CompareExchange(ref _closedTokenProvider, newTokenProvider, null) is not null) + State state = new(); + if (Interlocked.CompareExchange(ref _state, state, null) is not null) { - newTokenProvider.Dispose(); - throw new InvalidOperationException("Connection already started."); + state.Dispose(); + ThrowConnectionAlreadyStarted(); } - return ConnectAsync(cancellationToken); + return ConnectAsync(state, cancellationToken); + + //CancellationTokenProvider newTokenProvider = new(); + //if (Interlocked.CompareExchange(ref _closedTokenProvider, newTokenProvider, null) is not null) + //{ + // newTokenProvider.Dispose(); + // throw new InvalidOperationException("Connection already started."); + //} + + //if (Interlocked.CompareExchange(ref _state, new(), null) is not null) + // throw new InvalidOperationException("Connection already started."); + + //return ConnectAsync(cancellationToken); } - private protected async Task ConnectAsync(CancellationToken cancellationToken = default) + private protected async Task ConnectAsync(State state, CancellationToken cancellationToken = default) { + var connection = _connectionProvider.CreateConnection(); + var rateLimiter = _rateLimiterProvider.CreateRateLimiter(); + ConnectionState connectionState = new(connection, rateLimiter); + if (!state.TryIndicateConnecting(connectionState)) + { + connectionState.Dispose(); + ThrowConnectionAlreadyStarted(); + } + HandleConnecting(); - var connection = await _connectionProvider.CreateWebSocketConnectionAsync(Uri, cancellationToken).ConfigureAwait(false); - var state = _state = new(connection); - HandleConnected(); - state.StartReading(ReadAsync); + await connection.OpenAsync(Uri, cancellationToken).ConfigureAwait(false); + HandleConnected(state); + connectionState.StartReading(state, ReadAsync); + return connectionState; + + //HandleConnecting(); + //var connection = await _connectionProvider.CreateWebSocketConnectionAsync(Uri, cancellationToken).ConfigureAwait(false); + //var state = _state = new(connection); + //HandleConnected(); + //state.StartReading(ReadAsync); } /// @@ -188,17 +312,15 @@ private protected async Task ConnectAsync(CancellationToken cancellationToken = /// public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, string? statusDescription = null, CancellationToken cancellationToken = default) { - //var closedTokenProvider = Interlocked.Exchange(ref _closedTokenProvider, null) ?? throw new InvalidOperationException("Connection not started."); - - //closedTokenProvider.Cancel(); - var state = Interlocked.Exchange(ref _state, null); - if (state is null || !state.TryIndicateDisconnecting()) - throw new InvalidOperationException("Connection not started."); + if (state is null) + ThrowConnectionNotStarted(); - var connection = state.Connection; + if (!state.TryIndicateDisconnecting(out var connectionState)) + return; + var connection = connectionState.Connection; try { await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); @@ -210,15 +332,16 @@ public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus. throw; } - await state.ReadTask.ConfigureAwait(false); + await connectionState.ReadTask.ConfigureAwait(false); HandleClosed(); } private async Task ReadAsync(State state) { - var connection = state.Connection; - var token = state.DisconnectedTokenProvider.Token; + var connectionState = state.ConnectionState!; + var connection = connectionState.Connection; + var token = connectionState.DisconnectedTokenProvider.Token; try { using RentedArrayBufferWriter writer = new(DefaultBufferSize); @@ -232,7 +355,7 @@ private async Task ReadAsync(State state) break; writer.Advance(result.Count); - HandleMessageReceived(writer.WrittenMemory); + HandleMessageReceived(state, writer.WrittenMemory); writer.Clear(); } else @@ -243,11 +366,10 @@ private async Task ReadAsync(State state) { } - if (state.TryIndicateDisconnecting()) + if (state.TryIndicateDisconnecting(out _)) { - _state = null; - state.Dispose(); - HandleDisconnected((WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); + connectionState.Dispose(); + HandleDisconnected(state, (WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); } } @@ -258,94 +380,180 @@ public void Abort() if (state is null) return; - var disconnecting = state.TryIndicateDisconnecting(); - - state.Connection.Abort(); - - if (disconnecting) + if (state.TryIndicateDisconnecting(out var connectionState)) + { + connectionState.Connection.Abort(); HandleClosed(); + } } private protected virtual void OnConnected() { } - private protected ValueTask AbortAndReconnectAsync() + private protected ValueTask AbortAndReconnectAsync(State state) { - var state = Interlocked.Exchange(ref _state, null); - - if (state is null || !state.TryIndicateDisconnecting()) + if (!state.TryIndicateDisconnecting(out var connectionState)) return default; try { - state.Connection.Abort(); + connectionState.Connection.Abort(); } catch (Exception ex) { InvokeLog(LogMessage.Error(ex)); } - return ReconnectAsync(); + return ReconnectAsync(state); } public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { properties ??= _defaultPayloadProperties; + while (true) { var state = _state; if (state is null) - { - if (_closedTokenProvider is null) - throw new InvalidOperationException("Connection not started."); + ThrowConnectionNotStarted(); + + var task = properties.MessageFlags.HasFlag(WebSocketMessageFlags.BypassReady) ? state.ConnectedTask : state.ReadyTask; + ConnectionState connectionState; + if (!task.IsCompleted) + { if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + connectionState = await task.ConfigureAwait(false); + else { - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // - continue; + ThrowConnectionNotStarted(); + return; } - - throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting."); } + else + connectionState = state.ConnectionState!; - var result = await _rateLimiter.TryAcquireAsync().ConfigureAwait(false); + var exception = await TrySendConnectionPayloadAsync(connectionState, buffer, properties, cancellationToken).ConfigureAwait(false); + + if (exception is null) + return; + + if (!properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + ThrowConnectionNotStarted(); + + //var rateLimiter = connectionState.RateLimiter; + + //if (state is null) + //{ + // //if (_closedTokenProvider is null) + // // throw new InvalidOperationException("Connection not started."); + + // //if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + // //{ + // // await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // + // // continue; + // //} + + // //throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting."); + //} + + //var result = await rateLimiter.TryAcquireAsync().ConfigureAwait(false); + + //if (result.RateLimited) + //{ + // if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryRateLimit)) + // { + // await Task.Delay(result.ResetAfter, cancellationToken).ConfigureAwait(false); + // continue; + // } + + // throw new InvalidOperationException("Rate limit triggered."); + //} + + //try + //{ + // await connectionState.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, cancellationToken).ConfigureAwait(false); + //} + //catch (Exception ex) when (ex is not ArgumentException) + //{ + // cancellationToken.ThrowIfCancellationRequested(); + + // if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + // continue; + + // throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting.", ex); + //} + + //return; + } + } + + private protected static async ValueTask SendConnectionPayloadAsync(ConnectionState connectionState, ReadOnlyMemory buffer, WebSocketPayloadProperties properties, CancellationToken cancellationToken = default) + { + var exception = await TrySendConnectionPayloadAsync(connectionState, buffer, properties, cancellationToken).ConfigureAwait(false); + if (exception is null) + return; + + ThrowConnectionNotStarted(exception); + } + + private protected static async ValueTask TrySendConnectionPayloadAsync(ConnectionState connectionState, ReadOnlyMemory buffer, WebSocketPayloadProperties properties, CancellationToken cancellationToken = default) + { + var rateLimiter = connectionState.RateLimiter; + + var disconnectedToken = connectionState.DisconnectedTokenProvider.Token; + + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(disconnectedToken, cancellationToken); + var linkedToken = linkedTokenSource.Token; + + while (true) + { + var result = await rateLimiter.TryAcquireAsync().ConfigureAwait(false); if (result.RateLimited) { if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryRateLimit)) { - await Task.Delay(result.ResetAfter, cancellationToken).ConfigureAwait(false); + try + { + await Task.Delay(result.ResetAfter, linkedToken).ConfigureAwait(false); + } + catch (TaskCanceledException ex) + { + if (disconnectedToken.IsCancellationRequested) + return ex; + + throw; + } + continue; } - throw new InvalidOperationException("Rate limit triggered."); + ThrowRateLimitTriggered(result.ResetAfter); } try { - await state.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, cancellationToken).ConfigureAwait(false); + await connectionState.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, linkedToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not ArgumentException) { cancellationToken.ThrowIfCancellationRequested(); - if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) - continue; - - throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting.", ex); + return ex; } - return; + return null; } } private protected abstract bool Reconnect(WebSocketCloseStatus? status, string? description); - private protected async ValueTask ReconnectAsync() + private protected async ValueTask ReconnectAsync(State state) { - if (_closedTokenProvider is not { Token: var cancellationToken }) + if (state is not { ClosedTokenProvider.Token: var cancellationToken }) return; foreach (var delay in _reconnectStrategy.GetDelays()) @@ -359,9 +567,10 @@ private protected async ValueTask ReconnectAsync() return; } + ConnectionState connectionState; try { - await ConnectAsync(cancellationToken).ConfigureAwait(false); + connectionState = await ConnectAsync(state, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -371,7 +580,7 @@ private protected async ValueTask ReconnectAsync() try { - await TryResumeAsync(cancellationToken).ConfigureAwait(false); + await TryResumeAsync(connectionState, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -382,12 +591,11 @@ private protected async ValueTask ReconnectAsync() } } - private protected abstract ValueTask TryResumeAsync(CancellationToken cancellationToken = default); + private protected abstract ValueTask TryResumeAsync(ConnectionState state, CancellationToken cancellationToken = default); - private protected async void StartHeartbeating(double interval) + private protected async void StartHeartbeating(ConnectionState state, double interval) { - if (_state is not { DisconnectedTokenProvider.Token: var cancellationToken }) - return; + var cancellationToken = state.DisconnectedTokenProvider.Token; PeriodicTimer timer; @@ -408,8 +616,7 @@ private protected async void StartHeartbeating(double interval) try { await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false); - Console.WriteLine("Sending heartbeat"); - await HeartbeatAsync(cancellationToken).ConfigureAwait(false); + await HeartbeatAsync(state, cancellationToken).ConfigureAwait(false); } catch { @@ -419,11 +626,11 @@ private protected async void StartHeartbeating(double interval) } } - private protected abstract ValueTask HeartbeatAsync(CancellationToken cancellationToken = default); + private protected abstract ValueTask HeartbeatAsync(ConnectionState connectionState, CancellationToken cancellationToken = default); private protected virtual JsonPayload CreatePayload(ReadOnlyMemory payload) => JsonSerializer.Deserialize(payload.Span, Serialization.Default.JsonPayload)!; - private protected abstract Task ProcessPayloadAsync(JsonPayload payload); + private protected abstract Task ProcessPayloadAsync(State state, JsonPayload payload); private protected async void InvokeLog(LogMessage logMessage) { @@ -663,6 +870,24 @@ private async ValueTask AwaitEventAsync(ValueTask task) } } + [DoesNotReturn] + private static void ThrowConnectionAlreadyStarted() + { + throw new InvalidOperationException("Connection already started."); + } + + [DoesNotReturn] + private static void ThrowConnectionNotStarted(Exception? innerException = null) + { + throw new InvalidOperationException("Connection not started.", innerException); + } + + [DoesNotReturn] + private static void ThrowRateLimitTriggered(int resetAfter) + { + throw new InvalidOperationException("Rate limit triggered."); + } + public void Dispose() { Dispose(true); @@ -672,10 +897,6 @@ public void Dispose() protected virtual void Dispose(bool disposing) { if (disposing) - { _state?.Dispose(); - _rateLimiter.Dispose(); - _closedTokenProvider?.Dispose(); - } } } diff --git a/NetCord/Gateway/WebSockets/IWebSocketConnection.cs b/NetCord/Gateway/WebSockets/IWebSocketConnection.cs index bc45fb6f5..caaf0508b 100644 --- a/NetCord/Gateway/WebSockets/IWebSocketConnection.cs +++ b/NetCord/Gateway/WebSockets/IWebSocketConnection.cs @@ -6,6 +6,8 @@ public interface IWebSocketConnection : IDisposable public string? CloseStatusDescription { get; } + public ValueTask OpenAsync(Uri uri, CancellationToken cancellationToken = default); + public ValueTask SendAsync(ReadOnlyMemory buffer, WebSocketMessageType messageType, WebSocketMessageFlags messageFlags, CancellationToken cancellationToken = default); public ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default); diff --git a/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs b/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs index 05a7eb8da..cd80fd1f8 100644 --- a/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs +++ b/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs @@ -2,5 +2,5 @@ public interface IWebSocketConnectionProvider { - public ValueTask CreateWebSocketConnectionAsync(Uri uri, CancellationToken cancellationToken = default); + public IWebSocketConnection CreateConnection(); } diff --git a/NetCord/Gateway/WebSockets/WebSocketConnection.cs b/NetCord/Gateway/WebSockets/WebSocketConnection.cs index 4dfffcc0c..9046ff1ac 100644 --- a/NetCord/Gateway/WebSockets/WebSocketConnection.cs +++ b/NetCord/Gateway/WebSockets/WebSocketConnection.cs @@ -4,24 +4,17 @@ namespace NetCord.Gateway.WebSockets; internal sealed class WebSocketConnection : IWebSocketConnection { - private readonly ClientWebSocket _webSocket; - - public static async ValueTask CreateAsync(Uri uri, CancellationToken cancellationToken = default) - { - ClientWebSocket webSocket = new(); - await webSocket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); - return new WebSocketConnection(webSocket); - } - - private WebSocketConnection(ClientWebSocket webSocket) - { - _webSocket = webSocket; - } + private readonly ClientWebSocket _webSocket = new(); public int? CloseStatus => (int?)_webSocket.CloseStatus; public string? CloseStatusDescription => _webSocket.CloseStatusDescription; + public ValueTask OpenAsync(Uri uri, CancellationToken cancellationToken = default) + { + return new(_webSocket.ConnectAsync(uri, cancellationToken)); + } + public void Abort() { _webSocket.Abort(); diff --git a/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs b/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs index 4a9a12ae1..bc1422e3a 100644 --- a/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs +++ b/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs @@ -2,8 +2,8 @@ public class WebSocketConnectionProvider : IWebSocketConnectionProvider { - public ValueTask CreateWebSocketConnectionAsync(Uri uri, CancellationToken cancellationToken = default) + public IWebSocketConnection CreateConnection() { - return WebSocketConnection.CreateAsync(uri, cancellationToken); + return new WebSocketConnection(); } } diff --git a/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs index eb00d1303..834afe1b0 100644 --- a/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs +++ b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs @@ -1,8 +1,10 @@ namespace NetCord.Gateway.WebSockets; +[Flags] public enum WebSocketMessageFlags : byte { None = 0, - EndOfMessage = 1, - DisableCompression = 2, + EndOfMessage = 1 << 0, + DisableCompression = 1 << 1, + BypassReady = 1 << 7, } diff --git a/NetCord/Rest/RateLimits/GlobalRateLimiter.cs b/NetCord/Rest/RateLimits/GlobalRateLimiter.cs index ae67b00d6..49e5ea5d5 100644 --- a/NetCord/Rest/RateLimits/GlobalRateLimiter.cs +++ b/NetCord/Rest/RateLimits/GlobalRateLimiter.cs @@ -27,7 +27,7 @@ public ValueTask TryAcquireAsync() } } - return new(RateLimitAcquisitionResult.NoRateLimit()); + return new(RateLimitAcquisitionResult.NoRateLimit); } public ValueTask IndicateRateLimitAsync(long reset) diff --git a/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs b/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs index 58fa80c5c..7ee722dd8 100644 --- a/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs @@ -10,7 +10,7 @@ internal class NoRateLimitRouteRateLimiter : ITrackingRouteRateLimiter public ValueTask TryAcquireAsync() { - return new(RateLimitAcquisitionResult.NoRateLimit()); + return new(RateLimitAcquisitionResult.NoRateLimit); } public ValueTask CancelAcquireAsync(long timestamp) diff --git a/NetCord/Rest/RateLimits/RateLimitAcquisitionResult.cs b/NetCord/Rest/RateLimits/RateLimitAcquisitionResult.cs index 23d869c00..57236aa22 100644 --- a/NetCord/Rest/RateLimits/RateLimitAcquisitionResult.cs +++ b/NetCord/Rest/RateLimits/RateLimitAcquisitionResult.cs @@ -9,9 +9,9 @@ private RateLimitAcquisitionResult(int resetAfter, bool rateLimited, bool always AlwaysRetry = alwaysRetryOnce; } - public static RateLimitAcquisitionResult Retry() => new(0, false, true); + public static RateLimitAcquisitionResult Retry { get; } = new(0, false, true); - public static RateLimitAcquisitionResult NoRateLimit() => new(0, false, false); + public static RateLimitAcquisitionResult NoRateLimit { get; } = new(0, false, false); public static RateLimitAcquisitionResult RateLimit(int resetAfter) => new(resetAfter, true, false); diff --git a/NetCord/Rest/RateLimits/RouteRateLimiter.cs b/NetCord/Rest/RateLimits/RouteRateLimiter.cs index c7759f494..372eaaf01 100644 --- a/NetCord/Rest/RateLimits/RouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/RouteRateLimiter.cs @@ -34,7 +34,7 @@ public ValueTask TryAcquireAsync() _remaining--; } } - return new(RateLimitAcquisitionResult.NoRateLimit()); + return new(RateLimitAcquisitionResult.NoRateLimit); } public ValueTask CancelAcquireAsync(long timestamp) diff --git a/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs b/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs index f445c8d55..8718c6757 100644 --- a/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs @@ -15,11 +15,11 @@ public async ValueTask TryAcquireAsync() { await _semaphore.WaitAsync().ConfigureAwait(false); if (_retry) - return RateLimitAcquisitionResult.Retry(); + return RateLimitAcquisitionResult.Retry; _retry = true; - return RateLimitAcquisitionResult.NoRateLimit(); + return RateLimitAcquisitionResult.NoRateLimit; } public ValueTask CancelAcquireAsync(long timestamp) diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index d8930f907..6a50610a8 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -91,6 +91,15 @@ private static async Task Main() await _client.StartAsync(); await _client.ReadyAsync; + + //await _client.CloseAsync(); + + ////await _client.StartAsync(); + + //await _client.RequestGuildUsersAsync(new(0)); + + await Task.WhenAll(_client.CloseAsync(), _client.RequestGuildUsersAsync(new(0)).AsTask()); + //try //{ // await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); @@ -101,14 +110,14 @@ private static async Task Main() // Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); //} - for (int i = 0; i < 120; i++) - { - await _client.UpdatePresenceAsync(new(UserStatusType.Online) - { - Activities = [new($"wzium {i}", UserActivityType.Game)], - }); - Console.WriteLine(i); - } + //for (int i = 0; i < 120; i++) + //{ + // await _client.UpdatePresenceAsync(new(UserStatusType.Online) + // { + // Activities = [new($"wzium {i}", UserActivityType.Game)], + // }); + // Console.WriteLine(i); + //} await Task.Delay(-1); } From c1f15c507e7845afdda6dfe46324d856ccfca25a Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Sun, 25 Aug 2024 22:26:23 +0200 Subject: [PATCH 03/33] Remove commented code --- NetCord/Gateway/WebSocketClient.cs | 63 ------------------------------ 1 file changed, 63 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 4d2e03dbb..c108bc9bd 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -265,18 +265,6 @@ private protected Task StartAsync(CancellationToken cancellatio } return ConnectAsync(state, cancellationToken); - - //CancellationTokenProvider newTokenProvider = new(); - //if (Interlocked.CompareExchange(ref _closedTokenProvider, newTokenProvider, null) is not null) - //{ - // newTokenProvider.Dispose(); - // throw new InvalidOperationException("Connection already started."); - //} - - //if (Interlocked.CompareExchange(ref _state, new(), null) is not null) - // throw new InvalidOperationException("Connection already started."); - - //return ConnectAsync(cancellationToken); } private protected async Task ConnectAsync(State state, CancellationToken cancellationToken = default) @@ -295,12 +283,6 @@ private protected async Task ConnectAsync(State state, Cancella HandleConnected(state); connectionState.StartReading(state, ReadAsync); return connectionState; - - //HandleConnecting(); - //var connection = await _connectionProvider.CreateWebSocketConnectionAsync(Uri, cancellationToken).ConfigureAwait(false); - //var state = _state = new(connection); - //HandleConnected(); - //state.StartReading(ReadAsync); } /// @@ -442,51 +424,6 @@ public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPa if (!properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) ThrowConnectionNotStarted(); - - //var rateLimiter = connectionState.RateLimiter; - - //if (state is null) - //{ - // //if (_closedTokenProvider is null) - // // throw new InvalidOperationException("Connection not started."); - - // //if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) - // //{ - // // await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // - // // continue; - // //} - - // //throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting."); - //} - - //var result = await rateLimiter.TryAcquireAsync().ConfigureAwait(false); - - //if (result.RateLimited) - //{ - // if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryRateLimit)) - // { - // await Task.Delay(result.ResetAfter, cancellationToken).ConfigureAwait(false); - // continue; - // } - - // throw new InvalidOperationException("Rate limit triggered."); - //} - - //try - //{ - // await connectionState.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, cancellationToken).ConfigureAwait(false); - //} - //catch (Exception ex) when (ex is not ArgumentException) - //{ - // cancellationToken.ThrowIfCancellationRequested(); - - // if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) - // continue; - - // throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting.", ex); - //} - - //return; } } From 11d56186de73bd834a892968b8044d5cd9aa2e66 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Sun, 25 Aug 2024 22:33:36 +0200 Subject: [PATCH 04/33] Merge alpha --- .github/workflows/build.yml | 5 + Documentation/guides/advanced/sharding.md | 6 +- Documentation/guides/events/first-events.md | 6 +- Documentation/guides/events/intents.md | 4 +- .../guides/getting-started/coding.md | 6 +- .../Introduction/MessageCommandModule.cs | 1 - .../Introduction/UserCommandModule.cs | 1 - .../application-commands/introduction.md | 6 +- .../application-commands/localizations.md | 4 +- .../component-interactions/introduction.md | 6 +- .../services/text-commands/introduction.md | 6 +- .../NetCord.Hosting.AspNetCore.csproj | 2 +- .../ApplicationCommandInteractionHandler.cs | 7 +- .../ApplicationCommandResultHandler.cs | 34 ++ .../ApplicationCommandServiceOptions.cs | 36 +- .../AutocompleteInteractionHandler.cs | 7 +- .../AutocompleteInteractionResultHandler.cs | 25 ++ .../IApplicationCommandResultHandler.cs | 12 + .../IAutocompleteInteractionResultHandler.cs | 12 + .../Commands/CommandHandler.cs | 7 +- .../Commands/CommandResultHandler.cs | 32 ++ .../Commands/CommandServiceOptions.cs | 24 +- .../Commands/ICommandResultHandler.cs | 12 + .../ComponentInteractionHandler.cs | 7 +- .../ComponentInteractionResultHandler.cs | 34 ++ .../ComponentInteractionServiceOptions.cs | 21 +- .../IComponentInteractionResultHandler.cs | 12 + .../NetCord.Hosting.Services.csproj | 2 +- .../NetCord.Hosting/NetCord.Hosting.csproj | 2 +- NetCord.Services/Commands/CommandService.cs | 12 +- NetCord.Services/NetCord.Services.csproj | 2 +- NetCord/ApplicationEmoji.cs | 9 + NetCord/Attachment.cs | 5 + NetCord/AuditLogEvent.cs | 35 ++ NetCord/AutoModerationActionType.cs | 1 + NetCord/AutoModerationRuleEventType.cs | 1 + NetCord/AutoModerationRuleTriggerType.cs | 1 + NetCord/AvatarDecorationData.cs | 12 + NetCord/CodeBlock.cs | 2 +- NetCord/Components/Button.cs | 2 +- NetCord/Components/IButton.cs | 3 +- NetCord/Components/ICustomizableButton.cs | 7 + NetCord/Components/LinkButton.cs | 2 +- NetCord/Components/PremiumButton.cs | 10 + NetCord/CustomEmoji.cs | 67 ++++ NetCord/EmbedType.cs | 2 +- NetCord/Gateway/AuditLogEntry.cs | 10 +- .../EventArgs/GuildInviteDeleteEventArgs.cs | 12 - .../EventArgs/InviteDeleteEventArgs.cs | 12 + .../EventArgs/MessageReactionAddEventArgs.cs | 6 + .../MessageReactionRemoveEventArgs.cs | 4 + NetCord/Gateway/GatewayClient.cs | 16 +- NetCord/Gateway/GatewayIntents.cs | 2 +- .../GuildJoinRequestFormResponseFieldType.cs | 2 +- NetCord/Gateway/GuildJoinRequestStatus.cs | 2 +- NetCord/Gateway/IPartialMessage.cs | 339 ------------------ NetCord/Gateway/{GuildInvite.cs => Invite.cs} | 24 +- ...ntArgs.cs => JsonInviteDeleteEventArgs.cs} | 2 +- .../JsonMessageReactionAddEventArgs.cs | 9 + .../JsonMessageReactionRemoveEventArgs.cs | 6 + .../{JsonGuildInvite.cs => JsonInvite.cs} | 7 +- NetCord/Gateway/Message.cs | 48 ++- NetCord/Gateway/Platform.cs | 2 +- NetCord/Gateway/ShardedGatewayClient.cs | 32 +- NetCord/GuildEmoji.cs | 66 +--- NetCord/GuildUser.cs | 6 + NetCord/{IGuildInvite.cs => IInvite.cs} | 5 +- NetCord/ImageUrl.cs | 4 +- NetCord/IntegrationType.cs | 2 +- NetCord/Interaction.cs | 6 + NetCord/InteractionGuildReference.cs | 14 + ...nviteTargetType.cs => InviteTargetType.cs} | 2 +- NetCord/InviteType.cs | 8 + ...ttachmentPropertiesIEnumerableConverter.cs | 5 + ...Handling.cs => SafeStringEnumConverter.cs} | 33 +- NetCord/JsonModels/JsonAttachment.cs | 3 + .../JsonModels/JsonAvatarDecorationData.cs | 12 + NetCord/JsonModels/JsonComponent.cs | 3 + NetCord/JsonModels/JsonGuildUser.cs | 3 + NetCord/JsonModels/JsonInteraction.cs | 3 + .../JsonInteractionGuildReference.cs | 12 + NetCord/JsonModels/JsonMessage.cs | 10 +- NetCord/JsonModels/JsonMessageCall.cs | 12 + NetCord/JsonModels/JsonMessageSnapshot.cs | 9 + .../JsonModels/JsonMessageSnapshotMessage.cs | 33 ++ .../JsonSelectMenuDefaultValueType.cs | 2 +- NetCord/JsonModels/JsonUser.cs | 4 +- NetCord/MessagePollProperties.cs | 28 +- NetCord/MessageReferenceType.cs | 7 + NetCord/MessageSnapshot.cs | 11 + NetCord/MessageSnapshotMessage.cs | 57 +++ NetCord/NetCord.csproj | 2 +- NetCord/PartialGuildUser.cs | 23 +- NetCord/Permissions.cs | 5 + NetCord/ReactionType.cs | 7 + NetCord/Rest/ApplicationEmojiOptions.cs | 14 + NetCord/Rest/ApplicationEmojiProperties.cs | 12 + NetCord/Rest/AttachmentProperties.cs | 5 + .../ComponentProperties/ButtonProperties.cs | 2 +- .../ComponentProperties/IButtonProperties.cs | 23 +- .../ICustomizableButtonProperties.cs | 14 + .../LinkButtonProperties.cs | 7 +- .../PremiumButtonProperties.cs | 19 + NetCord/Rest/ConnectionType.cs | 11 +- NetCord/Rest/EmbedImageProperties.cs | 9 - .../Rest/ForumGuildThreadMessageProperties.cs | 2 +- NetCord/Rest/IMessageProperties.cs | 16 + NetCord/Rest/InteractionCallback.cs | 6 - NetCord/Rest/InteractionCallbackType.cs | 5 - NetCord/Rest/InteractionMessageProperties.cs | 2 +- ...nviteProperties.cs => InviteProperties.cs} | 4 +- ...onRestGuildInvite.cs => JsonRestInvite.cs} | 7 +- NetCord/Rest/MentionableValueProperties.cs | 2 +- NetCord/Rest/MessageCall.cs | 12 + NetCord/Rest/MessageProperties.cs | 2 +- .../MessageReactionsPaginationProperties.cs | 9 + NetCord/Rest/MessageReferenceProperties.cs | 38 +- NetCord/Rest/ReplyMessageProperties.cs | 6 +- NetCord/Rest/RestAuditLogEntry.cs | 2 +- NetCord/Rest/RestClient.AuditLog.cs | 6 +- NetCord/Rest/RestClient.Channel.cs | 48 +-- NetCord/Rest/RestClient.Emoji.cs | 31 ++ NetCord/Rest/RestClient.Guild.cs | 4 +- NetCord/Rest/RestClient.Invite.cs | 14 +- NetCord/Rest/RestClient.Poll.cs | 4 +- NetCord/Rest/RestGuildInvite.cs | 16 +- NetCord/Rest/RestMessage.cs | 21 +- NetCord/Rest/WebhookMessageProperties.cs | 2 +- NetCord/Serialization.cs | 13 +- NetCord/TeamRole.cs | 2 +- NetCord/User.cs | 23 +- NetCord/UserStatusType.cs | 2 +- .../RestClientMethodAliasesGenerator.cs | 2 +- .../CustomSlashCommandResultHandler.cs | 20 ++ Tests/NetCord.Test.Hosting/Program.cs | 5 +- .../Commands/Administrative/BanCommands.cs | 2 +- .../Commands/Administrative/MuteCommands.cs | 2 +- Tests/NetCord.Test/Commands/NormalCommands.cs | 4 +- .../NetCord.Test/Commands/StrangeCommands.cs | 10 +- .../localizations/localization.pl.pl.pl.json | 71 ---- 140 files changed, 1139 insertions(+), 836 deletions(-) create mode 100644 Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandResultHandler.cs create mode 100644 Hosting/NetCord.Hosting.Services/ApplicationCommands/AutocompleteInteractionResultHandler.cs create mode 100644 Hosting/NetCord.Hosting.Services/ApplicationCommands/IApplicationCommandResultHandler.cs create mode 100644 Hosting/NetCord.Hosting.Services/ApplicationCommands/IAutocompleteInteractionResultHandler.cs create mode 100644 Hosting/NetCord.Hosting.Services/Commands/CommandResultHandler.cs create mode 100644 Hosting/NetCord.Hosting.Services/Commands/ICommandResultHandler.cs create mode 100644 Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionResultHandler.cs create mode 100644 Hosting/NetCord.Hosting.Services/ComponentInteractions/IComponentInteractionResultHandler.cs create mode 100644 NetCord/ApplicationEmoji.cs create mode 100644 NetCord/AvatarDecorationData.cs create mode 100644 NetCord/Components/ICustomizableButton.cs create mode 100644 NetCord/Components/PremiumButton.cs create mode 100644 NetCord/CustomEmoji.cs delete mode 100644 NetCord/Gateway/EventArgs/GuildInviteDeleteEventArgs.cs create mode 100644 NetCord/Gateway/EventArgs/InviteDeleteEventArgs.cs delete mode 100644 NetCord/Gateway/IPartialMessage.cs rename NetCord/Gateway/{GuildInvite.cs => Invite.cs} (61%) rename NetCord/Gateway/JsonModels/EventArgs/{JsonGuildInviteDeleteEventArgs.cs => JsonInviteDeleteEventArgs.cs} (88%) rename NetCord/Gateway/JsonModels/{JsonGuildInvite.cs => JsonInvite.cs} (87%) rename NetCord/{IGuildInvite.cs => IInvite.cs} (79%) create mode 100644 NetCord/InteractionGuildReference.cs rename NetCord/{GuildInviteTargetType.cs => InviteTargetType.cs} (67%) create mode 100644 NetCord/InviteType.cs rename NetCord/JsonConverters/{StringEnumConverterWithErrorHandling.cs => SafeStringEnumConverter.cs} (70%) create mode 100644 NetCord/JsonModels/JsonAvatarDecorationData.cs create mode 100644 NetCord/JsonModels/JsonInteractionGuildReference.cs create mode 100644 NetCord/JsonModels/JsonMessageCall.cs create mode 100644 NetCord/JsonModels/JsonMessageSnapshot.cs create mode 100644 NetCord/JsonModels/JsonMessageSnapshotMessage.cs create mode 100644 NetCord/MessageReferenceType.cs create mode 100644 NetCord/MessageSnapshot.cs create mode 100644 NetCord/MessageSnapshotMessage.cs create mode 100644 NetCord/ReactionType.cs create mode 100644 NetCord/Rest/ApplicationEmojiOptions.cs create mode 100644 NetCord/Rest/ApplicationEmojiProperties.cs create mode 100644 NetCord/Rest/ComponentProperties/ICustomizableButtonProperties.cs create mode 100644 NetCord/Rest/ComponentProperties/PremiumButtonProperties.cs create mode 100644 NetCord/Rest/IMessageProperties.cs rename NetCord/Rest/{GuildInviteProperties.cs => InviteProperties.cs} (85%) rename NetCord/Rest/JsonModels/{JsonRestGuildInvite.cs => JsonRestInvite.cs} (90%) create mode 100644 NetCord/Rest/MessageCall.cs create mode 100644 NetCord/Rest/MessageReactionsPaginationProperties.cs create mode 100644 Tests/NetCord.Test.Hosting/CustomSlashCommandResultHandler.cs delete mode 100644 Tests/NetCord.Test/localizations/localization.pl.pl.pl.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a9db2811c..40f83e578 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,11 @@ on: branches-ignore: - stable - alpha + pull_request: + types: [opened, synchronize] + branches: + - stable + - alpha jobs: build: diff --git a/Documentation/guides/advanced/sharding.md b/Documentation/guides/advanced/sharding.md index 7d5055180..c1bdfdc8f 100644 --- a/Documentation/guides/advanced/sharding.md +++ b/Documentation/guides/advanced/sharding.md @@ -10,14 +10,14 @@ Sharding is splitting your bot into multiple @"NetCord.Gateway.GatewayClient"s. ## How to shard? -## [Hosting](#tab/hosting) +## [Generic Host](#tab/generic-host) -With hosting, to start sharding, instead of calling @NetCord.Hosting.Gateway.GatewayClientHostBuilderExtensions.UseDiscordGateway(Microsoft.Extensions.Hosting.IHostBuilder), you need to call @NetCord.Hosting.Gateway.ShardedGatewayClientHostBuilderExtensions.UseDiscordShardedGateway(Microsoft.Extensions.Hosting.IHostBuilder). Example: +To start sharding with the generic host, instead of calling @NetCord.Hosting.Gateway.GatewayClientHostBuilderExtensions.UseDiscordGateway(Microsoft.Extensions.Hosting.IHostBuilder), you need to call @NetCord.Hosting.Gateway.ShardedGatewayClientHostBuilderExtensions.UseDiscordShardedGateway(Microsoft.Extensions.Hosting.IHostBuilder). Example: [!code-cs[Program.cs](ShardingHosting/Program.cs)] Also note that you need to use @NetCord.Hosting.Gateway.IShardedGatewayEventHandler or @NetCord.Hosting.Gateway.IShardedGatewayEventHandler`1 instead of @NetCord.Hosting.Gateway.IGatewayEventHandler or @NetCord.Hosting.Gateway.IGatewayEventHandler`1 for event handlers. You also need to use @NetCord.Hosting.Gateway.GatewayEventHandlerServiceCollectionExtensions.AddShardedGatewayEventHandlers(Microsoft.Extensions.DependencyInjection.IServiceCollection,System.Reflection.Assembly) to add event handlers. -## [Without Hosting](#tab/without-hosting) +## [Bare Bones](#tab/bare-bones) To start sharding, you need to create an instance of @NetCord.Gateway.ShardedGatewayClient. Its usage is very similar to @NetCord.Gateway.GatewayClient. Example: [!code-cs[Program.cs](Sharding/Program.cs)] diff --git a/Documentation/guides/events/first-events.md b/Documentation/guides/events/first-events.md index c6ee916a2..f658ccee2 100644 --- a/Documentation/guides/events/first-events.md +++ b/Documentation/guides/events/first-events.md @@ -1,8 +1,8 @@ # First Events -## [Hosting](#tab/hosting) +## [Generic Host](#tab/generic-host) -With hosting, the preferred way to receive events is by implementing @NetCord.Hosting.Gateway.IGatewayEventHandler or @NetCord.Hosting.Gateway.IGatewayEventHandler`1. +The preferred way to receive events with the generic host is by implementing @NetCord.Hosting.Gateway.IGatewayEventHandler or @NetCord.Hosting.Gateway.IGatewayEventHandler`1. First, use @NetCord.Hosting.Gateway.GatewayEventHandlerServiceCollectionExtensions.AddGatewayEventHandlers(Microsoft.Extensions.DependencyInjection.IServiceCollection,System.Reflection.Assembly) to add all event handlers in an assembly. You also need to call @NetCord.Hosting.Gateway.GatewayEventHandlerHostExtensions.UseGatewayEventHandlers(Microsoft.Extensions.Hosting.IHost) to bind the handlers to the client. [!code-cs[Program.cs](FirstEventsHosting/Program.cs?highlight=18,21)] @@ -28,7 +28,7 @@ Other events work similar to these. You can play with them if you want! > [!NOTE] > When using @NetCord.Gateway.ShardedGatewayClient, you need to implement @NetCord.Hosting.Gateway.IShardedGatewayEventHandler or @NetCord.Hosting.Gateway.IShardedGatewayEventHandler`1 instead. You also need to use @NetCord.Hosting.Gateway.GatewayEventHandlerServiceCollectionExtensions.AddShardedGatewayEventHandlers(Microsoft.Extensions.DependencyInjection.IServiceCollection,System.Reflection.Assembly) to add event handlers instead. -## [Without Hosting](#tab/without-hosting) +## [Bare Bones](#tab/bare-bones) ### MessageCreate Event To listen to the event, add the following lines before `client.StartAsync()`! diff --git a/Documentation/guides/events/intents.md b/Documentation/guides/events/intents.md index 0f60ddd52..058ceeb90 100644 --- a/Documentation/guides/events/intents.md +++ b/Documentation/guides/events/intents.md @@ -12,10 +12,10 @@ Privileged intents are intents that you need to enable in [Discord Developer Por Intents in NetCord are handled by @NetCord.Gateway.GatewayIntents. You specify them like this: -## [Hosting](#tab/hosting) +## [Generic Host](#tab/generic-host) [!code-cs[Program.cs](IntentsHosting/Program.cs?highlight=6#L6-L13)] -## [Without Hosting](#tab/without-hosting) +## [Bare Bones](#tab/bare-bones) [!code-cs[Program.cs](Intents/Program.cs?highlight=3#L4-L7)] *** diff --git a/Documentation/guides/getting-started/coding.md b/Documentation/guides/getting-started/coding.md index 803fc7564..dee3770f0 100644 --- a/Documentation/guides/getting-started/coding.md +++ b/Documentation/guides/getting-started/coding.md @@ -10,9 +10,9 @@ Before we start, you need a token of your bot... so you need to go to the [Disco > [!IMPORTANT] > You should never give your token to anybody. -## [Hosting](#tab/hosting) +## [Generic Host](#tab/generic-host) -With hosting, you can just use @NetCord.Hosting.Gateway.GatewayClientHostBuilderExtensions.UseDiscordGateway(Microsoft.Extensions.Hosting.IHostBuilder) to add your bot to the host. Quite easy, right? +With the generic host, you can just use @NetCord.Hosting.Gateway.GatewayClientHostBuilderExtensions.UseDiscordGateway(Microsoft.Extensions.Hosting.IHostBuilder) to add your bot to the host. Quite easy, right? [!code-cs[Program.cs](CodingHosting/Program.cs)] Also note that the token needs to be stored in the configuration. You can for example use `appsettings.json` file. It should look like this: @@ -21,7 +21,7 @@ Also note that the token needs to be stored in the configuration. You can for ex Now, when you run the code, your bot should be online! ![](../../images/coding_BotOnline.png) -## [Without Hosting](#tab/without-hosting) +## [Bare Bones](#tab/bare-bones) Add the following lines to file `Program.cs`. [!code-cs[Program.cs](Coding/Program.cs#L1-L4)] diff --git a/Documentation/guides/services/application-commands/Introduction/MessageCommandModule.cs b/Documentation/guides/services/application-commands/Introduction/MessageCommandModule.cs index a6e9058d9..0a0ac78ef 100644 --- a/Documentation/guides/services/application-commands/Introduction/MessageCommandModule.cs +++ b/Documentation/guides/services/application-commands/Introduction/MessageCommandModule.cs @@ -7,4 +7,3 @@ public class MessageCommandModule : ApplicationCommandModule Context.Target.CreatedAt.ToString(); } - diff --git a/Documentation/guides/services/application-commands/Introduction/UserCommandModule.cs b/Documentation/guides/services/application-commands/Introduction/UserCommandModule.cs index 67ca082b8..28d2c8a5d 100644 --- a/Documentation/guides/services/application-commands/Introduction/UserCommandModule.cs +++ b/Documentation/guides/services/application-commands/Introduction/UserCommandModule.cs @@ -7,4 +7,3 @@ public class UserCommandModule : ApplicationCommandModule [UserCommand("ID")] public string Id() => Context.Target.Id.ToString(); } - diff --git a/Documentation/guides/services/application-commands/introduction.md b/Documentation/guides/services/application-commands/introduction.md index 75c640601..de5a98cd6 100644 --- a/Documentation/guides/services/application-commands/introduction.md +++ b/Documentation/guides/services/application-commands/introduction.md @@ -8,12 +8,12 @@ > > **must** be lowercase. -## [Hosting](#tab/hosting) +## [Generic Host](#tab/generic-host) -With hosting, adding application commands is very easy. Use @NetCord.Hosting.Services.ApplicationCommands.ApplicationCommandServiceHostBuilderExtensions.UseApplicationCommands``2(Microsoft.Extensions.Hosting.IHostBuilder) to add an application command service to your host builder. Then, use @NetCord.Hosting.Services.ApplicationCommands.ApplicationCommandServiceHostExtensions.AddSlashCommand*, @NetCord.Hosting.Services.ApplicationCommands.ApplicationCommandServiceHostExtensions.AddUserCommand* or @NetCord.Hosting.Services.ApplicationCommands.ApplicationCommandServiceHostExtensions.AddMessageCommand* to add an application command using the ASP.NET Core minimal APIs way and/or use @NetCord.Hosting.Services.ServicesHostExtensions.AddModules(Microsoft.Extensions.Hosting.IHost,System.Reflection.Assembly) to add modules from an assembly. You also need to use @NetCord.Hosting.Gateway.GatewayEventHandlerHostExtensions.UseGatewayEventHandlers(Microsoft.Extensions.Hosting.IHost) to bind the service event handlers. +Adding application commands with the generic host is very easy. Use @NetCord.Hosting.Services.ApplicationCommands.ApplicationCommandServiceHostBuilderExtensions.UseApplicationCommands``2(Microsoft.Extensions.Hosting.IHostBuilder) to add an application command service to your host builder. Then, use @NetCord.Hosting.Services.ApplicationCommands.ApplicationCommandServiceHostExtensions.AddSlashCommand*, @NetCord.Hosting.Services.ApplicationCommands.ApplicationCommandServiceHostExtensions.AddUserCommand* or @NetCord.Hosting.Services.ApplicationCommands.ApplicationCommandServiceHostExtensions.AddMessageCommand* to add an application command using the ASP.NET Core minimal APIs way and/or use @NetCord.Hosting.Services.ServicesHostExtensions.AddModules(Microsoft.Extensions.Hosting.IHost,System.Reflection.Assembly) to add modules from an assembly. You also need to use @NetCord.Hosting.Gateway.GatewayEventHandlerHostExtensions.UseGatewayEventHandlers(Microsoft.Extensions.Hosting.IHost) to bind the service event handlers. [!code-cs[Program.cs](IntroductionHosting/Program.cs?highlight=11-13,16-20)] -## [Without Hosting](#tab/without-hosting) +## [Bare Bones](#tab/bare-bones) First, add the following lines to using the section. [!code-cs[Program.cs](Introduction/Program.cs#L4-L5)] diff --git a/Documentation/guides/services/application-commands/localizations.md b/Documentation/guides/services/application-commands/localizations.md index d741fbb22..072f81b6b 100644 --- a/Documentation/guides/services/application-commands/localizations.md +++ b/Documentation/guides/services/application-commands/localizations.md @@ -6,10 +6,10 @@ To localize application commands, you need to use @NetCord.Services.ApplicationC The samples below show how to specify the @NetCord.Services.ApplicationCommands.JsonLocalizationsProvider. -## [Hosting](#tab/hosting) +## [Generic Host](#tab/generic-host) [!code-cs[Program.cs](LocalizationsHosting/Program.cs?highlight=6#L9-L16)] -## [Without Hosting](#tab/without-hosting) +## [Bare Bones](#tab/bare-bones) [!code-cs[Program.cs](Localizations/Program.cs?highlight=3#L12-L15)] *** diff --git a/Documentation/guides/services/component-interactions/introduction.md b/Documentation/guides/services/component-interactions/introduction.md index a0e3ddb98..709df4671 100644 --- a/Documentation/guides/services/component-interactions/introduction.md +++ b/Documentation/guides/services/component-interactions/introduction.md @@ -1,11 +1,11 @@ # Introduction -## [Hosting](#tab/hosting) +## [Generic Host](#tab/generic-host) -With hosting, adding component interactions is very easy. Use @NetCord.Hosting.Services.ComponentInteractions.ComponentInteractionServiceHostBuilderExtensions.UseComponentInteractions``2(Microsoft.Extensions.Hosting.IHostBuilder) to add a component interaction service to your host builder. Then, use @NetCord.Hosting.Services.ComponentInteractions.ComponentInteractionServiceHostExtensions.AddComponentInteraction* to add a component interaction using the ASP.NET Core minimal APIs way and/or use @NetCord.Hosting.Services.ServicesHostExtensions.AddModules(Microsoft.Extensions.Hosting.IHost,System.Reflection.Assembly) to add modules from an assembly. You also need to use @NetCord.Hosting.Gateway.GatewayEventHandlerHostExtensions.UseGatewayEventHandlers(Microsoft.Extensions.Hosting.IHost) to bind the service event handlers. +Adding component interactions with the generic host is very easy. Use @NetCord.Hosting.Services.ComponentInteractions.ComponentInteractionServiceHostBuilderExtensions.UseComponentInteractions``2(Microsoft.Extensions.Hosting.IHostBuilder) to add a component interaction service to your host builder. Then, use @NetCord.Hosting.Services.ComponentInteractions.ComponentInteractionServiceHostExtensions.AddComponentInteraction* to add a component interaction using the ASP.NET Core minimal APIs way and/or use @NetCord.Hosting.Services.ServicesHostExtensions.AddModules(Microsoft.Extensions.Hosting.IHost,System.Reflection.Assembly) to add modules from an assembly. You also need to use @NetCord.Hosting.Gateway.GatewayEventHandlerHostExtensions.UseGatewayEventHandlers(Microsoft.Extensions.Hosting.IHost) to bind the service event handlers. [!code-cs[Program.cs](IntroductionHosting/Program.cs?highlight=11-17,20-28)] -## [Without Hosting](#tab/without-hosting) +## [Bare Bones](#tab/bare-bones) First, add the following lines to the using section. [!code-cs[Program.cs](Introduction/Program.cs#L4-L5)] diff --git a/Documentation/guides/services/text-commands/introduction.md b/Documentation/guides/services/text-commands/introduction.md index 5e5ee20d9..8e34cd55c 100644 --- a/Documentation/guides/services/text-commands/introduction.md +++ b/Documentation/guides/services/text-commands/introduction.md @@ -1,8 +1,8 @@ # Introduction -## [Hosting](#tab/hosting) +## [Generic Host](#tab/generic-host) -With hosting, adding commands is very easy. Use @NetCord.Hosting.Services.Commands.CommandServiceHostBuilderExtensions.UseCommands``1(Microsoft.Extensions.Hosting.IHostBuilder) to add a command service to your host builder. Then, use @NetCord.Hosting.Services.Commands.CommandServiceHostExtensions.AddCommand* to add a command using the ASP.NET Core minimal APIs way and/or use @NetCord.Hosting.Services.ServicesHostExtensions.AddModules(Microsoft.Extensions.Hosting.IHost,System.Reflection.Assembly) to add modules from an assembly. You also need to use @NetCord.Hosting.Gateway.GatewayEventHandlerHostExtensions.UseGatewayEventHandlers(Microsoft.Extensions.Hosting.IHost) to bind the service event handlers. +Adding commands with the generic host is very easy. Use @NetCord.Hosting.Services.Commands.CommandServiceHostBuilderExtensions.UseCommands``1(Microsoft.Extensions.Hosting.IHostBuilder) to add a command service to your host builder. Then, use @NetCord.Hosting.Services.Commands.CommandServiceHostExtensions.AddCommand* to add a command using the ASP.NET Core minimal APIs way and/or use @NetCord.Hosting.Services.ServicesHostExtensions.AddModules(Microsoft.Extensions.Hosting.IHost,System.Reflection.Assembly) to add modules from an assembly. You also need to use @NetCord.Hosting.Gateway.GatewayEventHandlerHostExtensions.UseGatewayEventHandlers(Microsoft.Extensions.Hosting.IHost) to bind the service event handlers. [!code-cs[Program.cs](IntroductionHosting/Program.cs?highlight=10,13-15)] ### Specifying a prefix @@ -10,7 +10,7 @@ With hosting, adding commands is very easy. Use @NetCord.Hosting.Services.Comman You can specify a prefix in the configuration. You can for example use `appsettings.json` file. It should look like this: [!code-json[appsettings.json](IntroductionHosting/appsettings.json)] -## [Without Hosting](#tab/without-hosting) +## [Bare Bones](#tab/bare-bones) First, add the following lines to the using section. [!code-cs[Program.cs](Introduction/Program.cs#L3-L4)] diff --git a/Hosting/NetCord.Hosting.AspNetCore/NetCord.Hosting.AspNetCore.csproj b/Hosting/NetCord.Hosting.AspNetCore/NetCord.Hosting.AspNetCore.csproj index 347e44196..7cc919a7e 100644 --- a/Hosting/NetCord.Hosting.AspNetCore/NetCord.Hosting.AspNetCore.csproj +++ b/Hosting/NetCord.Hosting.AspNetCore/NetCord.Hosting.AspNetCore.csproj @@ -13,7 +13,7 @@ SmallSquare.png MIT $(VersionPrefix) - alpha.73 + alpha.82 The modern and fully customizable C# Discord library. true diff --git a/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandInteractionHandler.cs b/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandInteractionHandler.cs index fa0e2e6b3..54d496830 100644 --- a/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandInteractionHandler.cs +++ b/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandInteractionHandler.cs @@ -4,7 +4,6 @@ using NetCord.Gateway; using NetCord.Hosting.Gateway; -using NetCord.Services; using NetCord.Services.ApplicationCommands; namespace NetCord.Hosting.Services.ApplicationCommands; @@ -19,7 +18,7 @@ internal unsafe partial class ApplicationCommandInteractionHandler, Interaction, GatewayClient?, ValueTask> _handleAsync; private readonly Func _createContext; - private readonly Func _handleResultAsync; + private readonly IApplicationCommandResultHandler _resultHandler; private readonly GatewayClient? _client; public ApplicationCommandInteractionHandler(IServiceProvider services, @@ -44,7 +43,7 @@ public ApplicationCommandInteractionHandler(IServiceProvider services, _handleAsync = &HandleInteractionAsync; _createContext = optionsValue.CreateContext ?? ContextHelper.CreateContextDelegate(); - _handleResultAsync = optionsValue.HandleResultAsync; + _resultHandler = optionsValue.ResultHandler; _client = client; } @@ -89,7 +88,7 @@ private async ValueTask HandleInteractionAsyncCore(TInteraction interaction, Gat try { - await _handleResultAsync(result, interaction, client, _logger, services).ConfigureAwait(false); + await _resultHandler.HandleResultAsync(result, context, client, _logger, services).ConfigureAwait(false); } catch (Exception exceptionHandlerException) { diff --git a/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandResultHandler.cs b/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandResultHandler.cs new file mode 100644 index 000000000..ee815b7c7 --- /dev/null +++ b/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandResultHandler.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Rest; +using NetCord.Services; +using NetCord.Services.ApplicationCommands; + +namespace NetCord.Hosting.Services.ApplicationCommands; + +public class ApplicationCommandResultHandler(MessageFlags? messageFlags = null) : IApplicationCommandResultHandler where TContext : IApplicationCommandContext +{ + public ValueTask HandleResultAsync(IExecutionResult result, TContext context, GatewayClient? client, ILogger logger, IServiceProvider services) + { + if (result is not IFailResult failResult) + return default; + + var resultMessage = failResult.Message; + + var interaction = context.Interaction; + + if (failResult is IExceptionResult exceptionResult) + logger.LogError(exceptionResult.Exception, "Execution of an application command of name '{Name}' failed with an exception", interaction.Data.Name); + else + logger.LogDebug("Execution of an application command of name '{Name}' failed with '{Message}'", interaction.Data.Name, resultMessage); + + InteractionMessageProperties message = new() + { + Content = resultMessage, + Flags = messageFlags, + }; + + return new(interaction.SendResponseAsync(InteractionCallback.Message(message))); + } +} diff --git a/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandServiceOptions.cs b/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandServiceOptions.cs index 559c37fe9..29af31e23 100644 --- a/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandServiceOptions.cs +++ b/Hosting/NetCord.Hosting.Services/ApplicationCommands/ApplicationCommandServiceOptions.cs @@ -1,8 +1,4 @@ -using Microsoft.Extensions.Logging; - -using NetCord.Gateway; -using NetCord.Rest; -using NetCord.Services; +using NetCord.Gateway; using NetCord.Services.ApplicationCommands; namespace NetCord.Hosting.Services.ApplicationCommands; @@ -15,38 +11,12 @@ public class ApplicationCommandServiceOptions where TInt public Func? CreateContext { get; set; } - public Func HandleResultAsync { get; set; } = (result, interaction, client, logger, services) => - { - if (result is not IFailResult failResult) - return default; - - var message = failResult.Message; - - if (failResult is IExceptionResult exceptionResult) - logger.LogError(exceptionResult.Exception, "Execution of an application command of name '{Name}' failed with an exception", interaction.Data.Name); - else - logger.LogDebug("Execution of an application command of name '{Name}' failed with '{Message}'", interaction.Data.Name, message); - - return new(interaction.SendResponseAsync(InteractionCallback.Message(message))); - }; + public IApplicationCommandResultHandler ResultHandler { get; set; } = new ApplicationCommandResultHandler(); } public class ApplicationCommandServiceOptions : ApplicationCommandServiceOptions where TInteraction : ApplicationCommandInteraction where TContext : IApplicationCommandContext where TAutocompleteContext : IAutocompleteInteractionContext { public Func? CreateAutocompleteContext { get; set; } - public Func HandleAutocompleteResultAsync { get; set; } = (result, interaction, client, logger, services) => - { - if (result is not IFailResult failResult) - return default; - - var commandName = interaction.Data.Name; - - if (failResult is IExceptionResult exceptionResult) - logger.LogError(exceptionResult.Exception, "Execution of an autocomplete for application command of name '{Name}' failed with an exception", commandName); - else - logger.LogDebug("Execution of an autocomplete for application command of name '{Name}' failed with '{Message}'", commandName, failResult.Message); - - return default; - }; + public IAutocompleteInteractionResultHandler AutocompleteResultHandler { get; set; } = new AutocompleteInteractionResultHandler(); } diff --git a/Hosting/NetCord.Hosting.Services/ApplicationCommands/AutocompleteInteractionHandler.cs b/Hosting/NetCord.Hosting.Services/ApplicationCommands/AutocompleteInteractionHandler.cs index 36a80d1f3..4b72bba25 100644 --- a/Hosting/NetCord.Hosting.Services/ApplicationCommands/AutocompleteInteractionHandler.cs +++ b/Hosting/NetCord.Hosting.Services/ApplicationCommands/AutocompleteInteractionHandler.cs @@ -4,7 +4,6 @@ using NetCord.Gateway; using NetCord.Hosting.Gateway; -using NetCord.Services; using NetCord.Services.ApplicationCommands; namespace NetCord.Hosting.Services.ApplicationCommands; @@ -19,7 +18,7 @@ internal unsafe partial class AutocompleteInteractionHandler, Interaction, GatewayClient?, ValueTask> _handleAsync; private readonly Func _createContext; - private readonly Func _handleResultAsync; + private readonly IAutocompleteInteractionResultHandler _resultHandler; private readonly GatewayClient? _client; public AutocompleteInteractionHandler(IServiceProvider services, @@ -44,7 +43,7 @@ public AutocompleteInteractionHandler(IServiceProvider services, _handleAsync = &HandleInteractionAsync; _createContext = optionsValue.CreateAutocompleteContext ?? ContextHelper.CreateContextDelegate(); - _handleResultAsync = optionsValue.HandleAutocompleteResultAsync; + _resultHandler = optionsValue.AutocompleteResultHandler; _client = client; } @@ -89,7 +88,7 @@ private async ValueTask HandleInteractionAsyncCore(AutocompleteInteraction inter try { - await _handleResultAsync(result, interaction, client, _logger, services).ConfigureAwait(false); + await _resultHandler.HandleResultAsync(result, context, client, _logger, services).ConfigureAwait(false); } catch (Exception exceptionHandlerException) { diff --git a/Hosting/NetCord.Hosting.Services/ApplicationCommands/AutocompleteInteractionResultHandler.cs b/Hosting/NetCord.Hosting.Services/ApplicationCommands/AutocompleteInteractionResultHandler.cs new file mode 100644 index 000000000..ae436fe75 --- /dev/null +++ b/Hosting/NetCord.Hosting.Services/ApplicationCommands/AutocompleteInteractionResultHandler.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Services; +using NetCord.Services.ApplicationCommands; + +namespace NetCord.Hosting.Services.ApplicationCommands; + +public class AutocompleteInteractionResultHandler : IAutocompleteInteractionResultHandler where TAutocompleteContext : IAutocompleteInteractionContext +{ + public ValueTask HandleResultAsync(IExecutionResult result, TAutocompleteContext context, GatewayClient? client, ILogger logger, IServiceProvider services) + { + if (result is not IFailResult failResult) + return default; + + var commandName = context.Interaction.Data.Name; + + if (failResult is IExceptionResult exceptionResult) + logger.LogError(exceptionResult.Exception, "Execution of an autocomplete for application command of name '{Name}' failed with an exception", commandName); + else + logger.LogDebug("Execution of an autocomplete for application command of name '{Name}' failed with '{Message}'", commandName, failResult.Message); + + return default; + } +} diff --git a/Hosting/NetCord.Hosting.Services/ApplicationCommands/IApplicationCommandResultHandler.cs b/Hosting/NetCord.Hosting.Services/ApplicationCommands/IApplicationCommandResultHandler.cs new file mode 100644 index 000000000..61eaf5761 --- /dev/null +++ b/Hosting/NetCord.Hosting.Services/ApplicationCommands/IApplicationCommandResultHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Services; +using NetCord.Services.ApplicationCommands; + +namespace NetCord.Hosting.Services.ApplicationCommands; + +public interface IApplicationCommandResultHandler where TContext : IApplicationCommandContext +{ + public ValueTask HandleResultAsync(IExecutionResult result, TContext context, GatewayClient? client, ILogger logger, IServiceProvider services); +} diff --git a/Hosting/NetCord.Hosting.Services/ApplicationCommands/IAutocompleteInteractionResultHandler.cs b/Hosting/NetCord.Hosting.Services/ApplicationCommands/IAutocompleteInteractionResultHandler.cs new file mode 100644 index 000000000..50a0cb7c3 --- /dev/null +++ b/Hosting/NetCord.Hosting.Services/ApplicationCommands/IAutocompleteInteractionResultHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Services; +using NetCord.Services.ApplicationCommands; + +namespace NetCord.Hosting.Services.ApplicationCommands; + +public interface IAutocompleteInteractionResultHandler where TAutocompleteContext : IAutocompleteInteractionContext +{ + public ValueTask HandleResultAsync(IExecutionResult result, TAutocompleteContext context, GatewayClient? client, ILogger logger, IServiceProvider services); +} diff --git a/Hosting/NetCord.Hosting.Services/Commands/CommandHandler.cs b/Hosting/NetCord.Hosting.Services/Commands/CommandHandler.cs index d0577dea1..258a6d996 100644 --- a/Hosting/NetCord.Hosting.Services/Commands/CommandHandler.cs +++ b/Hosting/NetCord.Hosting.Services/Commands/CommandHandler.cs @@ -4,7 +4,6 @@ using NetCord.Gateway; using NetCord.Hosting.Gateway; -using NetCord.Services; using NetCord.Services.Commands; namespace NetCord.Hosting.Services.Commands; @@ -20,7 +19,7 @@ internal unsafe partial class CommandHandler : IGatewayEventHandler, Message, GatewayClient, ValueTask> _handleAsync; private readonly Func> _getPrefixLengthAsync; private readonly Func _createContext; - private readonly Func _handleResultAsync; + private readonly ICommandResultHandler _resultHandler; private readonly GatewayClient? _client; public CommandHandler(IServiceProvider services, @@ -46,7 +45,7 @@ public CommandHandler(IServiceProvider services, _getPrefixLengthAsync = GetGetPrefixLengthAsyncDelegate(optionsValue); _createContext = optionsValue.CreateContext ?? ContextHelper.CreateContextDelegate(); - _handleResultAsync = optionsValue.HandleResultAsync; + _resultHandler = optionsValue.ResultHandler; _client = client; } @@ -134,7 +133,7 @@ private async ValueTask HandleMessageAsyncCore(Message message, GatewayClient cl try { - await _handleResultAsync(result, message, client, _logger, services).ConfigureAwait(false); + await _resultHandler.HandleResultAsync(result, context, client, _logger, services).ConfigureAwait(false); } catch (Exception exceptionHandlerException) { diff --git a/Hosting/NetCord.Hosting.Services/Commands/CommandResultHandler.cs b/Hosting/NetCord.Hosting.Services/Commands/CommandResultHandler.cs new file mode 100644 index 000000000..d8d009bae --- /dev/null +++ b/Hosting/NetCord.Hosting.Services/Commands/CommandResultHandler.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Services; +using NetCord.Services.Commands; + +namespace NetCord.Hosting.Services.Commands; + +public class CommandResultHandler(MessageFlags? messageFlags = null) : ICommandResultHandler where TContext : ICommandContext +{ + public ValueTask HandleResultAsync(IExecutionResult result, TContext context, GatewayClient client, ILogger logger, IServiceProvider services) + { + if (result is not IFailResult failResult) + return default; + + var resultMessage = failResult.Message; + + var message = context.Message; + + if (failResult is IExceptionResult exceptionResult) + logger.LogError(exceptionResult.Exception, "Execution of a command with content '{Content}' failed with an exception", message.Content); + else + logger.LogDebug("Execution of a command with content '{Content}' failed with '{Message}'", message.Content, resultMessage); + + return new(message.ReplyAsync(new() + { + Content = resultMessage, + FailIfNotExists = false, + Flags = messageFlags, + })); + } +} diff --git a/Hosting/NetCord.Hosting.Services/Commands/CommandServiceOptions.cs b/Hosting/NetCord.Hosting.Services/Commands/CommandServiceOptions.cs index b59f27d2d..de132b816 100644 --- a/Hosting/NetCord.Hosting.Services/Commands/CommandServiceOptions.cs +++ b/Hosting/NetCord.Hosting.Services/Commands/CommandServiceOptions.cs @@ -1,7 +1,4 @@ -using Microsoft.Extensions.Logging; - -using NetCord.Gateway; -using NetCord.Services; +using NetCord.Gateway; using NetCord.Services.Commands; namespace NetCord.Hosting.Services.Commands; @@ -20,22 +17,5 @@ public class CommandServiceOptions where TContext : ICommandContext public Func? CreateContext { get; set; } - public Func HandleResultAsync { get; set; } = (result, message, client, logger, services) => - { - if (result is not IFailResult failResult) - return default; - - string resultMessage = failResult.Message; - - if (failResult is IExceptionResult exceptionResult) - logger.LogError(exceptionResult.Exception, "Execution of a command with content '{Content}' failed with an exception", message.Content); - else - logger.LogDebug("Execution of a command with content '{Content}' failed with '{Message}'", message.Content, resultMessage); - - return new(message.ReplyAsync(new() - { - Content = resultMessage, - FailIfNotExists = false, - })); - }; + public ICommandResultHandler ResultHandler { get; set; } = new CommandResultHandler(); } diff --git a/Hosting/NetCord.Hosting.Services/Commands/ICommandResultHandler.cs b/Hosting/NetCord.Hosting.Services/Commands/ICommandResultHandler.cs new file mode 100644 index 000000000..bd3a41339 --- /dev/null +++ b/Hosting/NetCord.Hosting.Services/Commands/ICommandResultHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Services; +using NetCord.Services.Commands; + +namespace NetCord.Hosting.Services.Commands; + +public interface ICommandResultHandler where TContext : ICommandContext +{ + public ValueTask HandleResultAsync(IExecutionResult result, TContext context, GatewayClient client, ILogger logger, IServiceProvider services); +} diff --git a/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionHandler.cs b/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionHandler.cs index 12ff83dc7..7a8414132 100644 --- a/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionHandler.cs +++ b/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionHandler.cs @@ -4,7 +4,6 @@ using NetCord.Gateway; using NetCord.Hosting.Gateway; -using NetCord.Services; using NetCord.Services.ComponentInteractions; namespace NetCord.Hosting.Services.ComponentInteractions; @@ -19,7 +18,7 @@ internal unsafe partial class ComponentInteractionHandler, Interaction, GatewayClient?, ValueTask> _handleAsync; private readonly Func _createContext; - private readonly Func _handleResultAsync; + private readonly IComponentInteractionResultHandler _resultHandler; private readonly GatewayClient? _client; public ComponentInteractionHandler(IServiceProvider services, @@ -44,7 +43,7 @@ public ComponentInteractionHandler(IServiceProvider services, _handleAsync = &HandleInteractionAsync; _createContext = optionsValue.CreateContext ?? ContextHelper.CreateContextDelegate(); - _handleResultAsync = optionsValue.HandleResultAsync; + _resultHandler = optionsValue.ResultHandler; _client = client; } @@ -89,7 +88,7 @@ private async ValueTask HandleInteractionAsyncCore(TInteraction interaction, Gat try { - await _handleResultAsync(result, interaction, client, _logger, services).ConfigureAwait(false); + await _resultHandler.HandleResultAsync(result, context, client, _logger, services).ConfigureAwait(false); } catch (Exception exceptionHandlerException) { diff --git a/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionResultHandler.cs b/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionResultHandler.cs new file mode 100644 index 000000000..e8c8d01cb --- /dev/null +++ b/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionResultHandler.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Rest; +using NetCord.Services; +using NetCord.Services.ComponentInteractions; + +namespace NetCord.Hosting.Services.ComponentInteractions; + +public class ComponentInteractionResultHandler(MessageFlags? messageFlags = null) : IComponentInteractionResultHandler where TContext : IComponentInteractionContext +{ + public ValueTask HandleResultAsync(IExecutionResult result, TContext context, GatewayClient? client, ILogger logger, IServiceProvider services) + { + if (result is not IFailResult failResult) + return default; + + var resultMessage = failResult.Message; + + var interaction = context.Interaction; + + if (failResult is IExceptionResult exceptionResult) + logger.LogError(exceptionResult.Exception, "Execution of an interaction of custom ID '{Id}' failed with an exception", interaction.Id); + else + logger.LogDebug("Execution of an interaction of custom ID '{Id}' failed with '{Message}'", interaction.Id, resultMessage); + + InteractionMessageProperties message = new() + { + Content = resultMessage, + Flags = messageFlags, + }; + + return new(interaction.SendResponseAsync(InteractionCallback.Message(message))); + } +} diff --git a/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionServiceOptions.cs b/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionServiceOptions.cs index f77427b34..21d5d51fb 100644 --- a/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionServiceOptions.cs +++ b/Hosting/NetCord.Hosting.Services/ComponentInteractions/ComponentInteractionServiceOptions.cs @@ -1,8 +1,4 @@ -using Microsoft.Extensions.Logging; - -using NetCord.Gateway; -using NetCord.Rest; -using NetCord.Services; +using NetCord.Gateway; using NetCord.Services.ComponentInteractions; namespace NetCord.Hosting.Services.ComponentInteractions; @@ -15,18 +11,5 @@ public class ComponentInteractionServiceOptions where TI public Func? CreateContext { get; set; } - public Func HandleResultAsync { get; set; } = (result, interaction, client, logger, services) => - { - if (result is not IFailResult failResult) - return default; - - var message = failResult.Message; - - if (failResult is IExceptionResult exceptionResult) - logger.LogError(exceptionResult.Exception, "Execution of an interaction of custom ID '{Id}' failed with an exception", interaction.Id); - else - logger.LogDebug("Execution of an interaction of custom ID '{Id}' failed with '{Message}'", interaction.Id, message); - - return new(interaction.SendResponseAsync(InteractionCallback.Message(message))); - }; + public IComponentInteractionResultHandler ResultHandler { get; set; } = new ComponentInteractionResultHandler(); } diff --git a/Hosting/NetCord.Hosting.Services/ComponentInteractions/IComponentInteractionResultHandler.cs b/Hosting/NetCord.Hosting.Services/ComponentInteractions/IComponentInteractionResultHandler.cs new file mode 100644 index 000000000..ea5b6a073 --- /dev/null +++ b/Hosting/NetCord.Hosting.Services/ComponentInteractions/IComponentInteractionResultHandler.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Services; +using NetCord.Services.ComponentInteractions; + +namespace NetCord.Hosting.Services.ComponentInteractions; + +public interface IComponentInteractionResultHandler where TContext : IComponentInteractionContext +{ + public ValueTask HandleResultAsync(IExecutionResult result, TContext context, GatewayClient? client, ILogger logger, IServiceProvider services); +} diff --git a/Hosting/NetCord.Hosting.Services/NetCord.Hosting.Services.csproj b/Hosting/NetCord.Hosting.Services/NetCord.Hosting.Services.csproj index 96d65e326..bbd8d8f0c 100644 --- a/Hosting/NetCord.Hosting.Services/NetCord.Hosting.Services.csproj +++ b/Hosting/NetCord.Hosting.Services/NetCord.Hosting.Services.csproj @@ -14,7 +14,7 @@ SmallSquare.png MIT $(VersionPrefix) - alpha.81 + alpha.90 The modern and fully customizable C# Discord library. true diff --git a/Hosting/NetCord.Hosting/NetCord.Hosting.csproj b/Hosting/NetCord.Hosting/NetCord.Hosting.csproj index 32e79f22d..b70ac12c6 100644 --- a/Hosting/NetCord.Hosting/NetCord.Hosting.csproj +++ b/Hosting/NetCord.Hosting/NetCord.Hosting.csproj @@ -13,7 +13,7 @@ SmallSquare.png MIT $(VersionPrefix) - alpha.72 + alpha.81 The modern and fully customizable C# Discord library. true diff --git a/NetCord.Services/Commands/CommandService.cs b/NetCord.Services/Commands/CommandService.cs index f09d98443..9f39cb8b9 100644 --- a/NetCord.Services/Commands/CommandService.cs +++ b/NetCord.Services/Commands/CommandService.cs @@ -108,26 +108,26 @@ private async ValueTask ExecuteAsyncCore(int prefixLength, TCo var index = fullCommand.Span.IndexOfAny(separators); SortedList>? commandInfos; ReadOnlyMemory baseArguments; - if (index == -1) + if (index >= 0) { - var command = fullCommand; + var command = fullCommand[..index]; if (!TryGetCommandInfos(command, out commandInfos)) return new NotFoundResult("Command not found."); - baseArguments = default; + baseArguments = fullCommand[(index + 1)..].TrimStart(separators); } else { - var command = fullCommand[..index]; + var command = fullCommand; if (!TryGetCommandInfos(command, out commandInfos)) return new NotFoundResult("Command not found."); - baseArguments = fullCommand[(index + 1)..]; + baseArguments = default; } - var configuration = _configuration; + var configuration = _configuration; var maxIndex = commandInfos.Count - 1; for (var i = 0; i <= maxIndex; i++) diff --git a/NetCord.Services/NetCord.Services.csproj b/NetCord.Services/NetCord.Services.csproj index e7a80b88c..e99d12c2c 100644 --- a/NetCord.Services/NetCord.Services.csproj +++ b/NetCord.Services/NetCord.Services.csproj @@ -14,7 +14,7 @@ SmallSquare.png MIT $(VersionPrefix) - alpha.203 + alpha.212 The modern and fully customizable C# Discord library. true diff --git a/NetCord/ApplicationEmoji.cs b/NetCord/ApplicationEmoji.cs new file mode 100644 index 000000000..934c882b2 --- /dev/null +++ b/NetCord/ApplicationEmoji.cs @@ -0,0 +1,9 @@ +using NetCord.JsonModels; +using NetCord.Rest; + +namespace NetCord; + +public partial class ApplicationEmoji(JsonEmoji jsonModel, ulong applicationId, RestClient client) : CustomEmoji(jsonModel, client) +{ + public ulong ApplicationId { get; } = applicationId; +} diff --git a/NetCord/Attachment.cs b/NetCord/Attachment.cs index f25208baa..63157fdcb 100644 --- a/NetCord/Attachment.cs +++ b/NetCord/Attachment.cs @@ -14,6 +14,11 @@ public class Attachment(JsonModels.JsonAttachment jsonModel) : Entity, IJsonMode /// public string FileName => _jsonModel.FileName; + /// + /// Title of the attachment. + /// + public string? Title => _jsonModel.Title; + /// /// Description for the attachment (max 1024 characters). /// diff --git a/NetCord/AuditLogEvent.cs b/NetCord/AuditLogEvent.cs index 195ec9770..90ca94ddb 100644 --- a/NetCord/AuditLogEvent.cs +++ b/NetCord/AuditLogEvent.cs @@ -281,4 +281,39 @@ public enum AuditLogEvent /// Creator monetization terms were accepted. /// CreatorMonetizationTermsAccepted = 151, + + /// + /// Guild onboarding question was created. + /// + OnboardingPromptCreate = 163, + + /// + /// Guild onboarding question was updated. + /// + OnboardingPromptUpdate = 164, + + /// + /// Guild onboarding question was deleted. + /// + OnboardingPromptDelete = 165, + + /// + /// Guild onboarding was created. + /// + OnboardingCreate = 166, + + /// + /// Guild onboarding was updated. + /// + OnboardingUpdate = 167, + + /// + /// Server guide was created. + /// + HomeSettingsCreate = 190, + + /// + /// Server guide was updated. + /// + HomeSettingsUpdate = 191, } diff --git a/NetCord/AutoModerationActionType.cs b/NetCord/AutoModerationActionType.cs index 0c4552c98..871e06e42 100644 --- a/NetCord/AutoModerationActionType.cs +++ b/NetCord/AutoModerationActionType.cs @@ -5,4 +5,5 @@ public enum AutoModerationActionType BlockMessage = 1, SendAlertMessage = 2, Timeout = 3, + BlockUserInteraction = 4, } diff --git a/NetCord/AutoModerationRuleEventType.cs b/NetCord/AutoModerationRuleEventType.cs index 19def3fa8..53bceaf10 100644 --- a/NetCord/AutoModerationRuleEventType.cs +++ b/NetCord/AutoModerationRuleEventType.cs @@ -3,4 +3,5 @@ public enum AutoModerationRuleEventType { MessageSend = 1, + UserUpdate = 2, } diff --git a/NetCord/AutoModerationRuleTriggerType.cs b/NetCord/AutoModerationRuleTriggerType.cs index f493143af..5e7da7283 100644 --- a/NetCord/AutoModerationRuleTriggerType.cs +++ b/NetCord/AutoModerationRuleTriggerType.cs @@ -6,4 +6,5 @@ public enum AutoModerationRuleTriggerType Spam = 3, KeywordPreset = 4, MentionSpam = 5, + UserProfile = 6, } diff --git a/NetCord/AvatarDecorationData.cs b/NetCord/AvatarDecorationData.cs new file mode 100644 index 000000000..8281b0bd3 --- /dev/null +++ b/NetCord/AvatarDecorationData.cs @@ -0,0 +1,12 @@ +using NetCord.JsonModels; + +namespace NetCord; + +public class AvatarDecorationData(JsonAvatarDecorationData jsonModel) : IJsonModel +{ + JsonAvatarDecorationData IJsonModel.JsonModel => jsonModel; + + public string Hash => jsonModel.Hash; + + public ulong SkuId => jsonModel.SkuId; +} diff --git a/NetCord/CodeBlock.cs b/NetCord/CodeBlock.cs index ada3f51c5..f6b12de1a 100644 --- a/NetCord/CodeBlock.cs +++ b/NetCord/CodeBlock.cs @@ -69,7 +69,7 @@ public static bool TryParse(ReadOnlySpan s, bool strictMode, [MaybeNullWhe foreach (var c in formatterSpan) { - if (char.IsAsciiLetterOrDigit(c) || c == '+' || c == '-') + if (char.IsAsciiLetterOrDigit(c) || c is '+' or '-' or '#' or '_') continue; goto Success; diff --git a/NetCord/Components/Button.cs b/NetCord/Components/Button.cs index a0785b624..98409dda9 100644 --- a/NetCord/Components/Button.cs +++ b/NetCord/Components/Button.cs @@ -1,6 +1,6 @@ namespace NetCord; -public class Button : IButton, IJsonModel +public class Button : ICustomizableButton, IJsonModel { JsonModels.JsonComponent IJsonModel.JsonModel => _jsonModel; private readonly JsonModels.JsonComponent _jsonModel; diff --git a/NetCord/Components/IButton.cs b/NetCord/Components/IButton.cs index ce12a6e22..dc4852ed4 100644 --- a/NetCord/Components/IButton.cs +++ b/NetCord/Components/IButton.cs @@ -2,8 +2,6 @@ public interface IButton { - public string? Label { get; } - public EmojiReference? Emoji { get; } public bool Disabled { get; } public static IButton CreateFromJson(JsonModels.JsonComponent jsonModel) @@ -11,6 +9,7 @@ public static IButton CreateFromJson(JsonModels.JsonComponent jsonModel) return jsonModel.Style.GetValueOrDefault() switch { (ButtonStyle)5 => new LinkButton(jsonModel), + (ButtonStyle)6 => new PremiumButton(jsonModel), _ => new Button(jsonModel), }; } diff --git a/NetCord/Components/ICustomizableButton.cs b/NetCord/Components/ICustomizableButton.cs new file mode 100644 index 000000000..0bf263d98 --- /dev/null +++ b/NetCord/Components/ICustomizableButton.cs @@ -0,0 +1,7 @@ +namespace NetCord; + +public interface ICustomizableButton : IButton +{ + public string? Label { get; } + public EmojiReference? Emoji { get; } +} diff --git a/NetCord/Components/LinkButton.cs b/NetCord/Components/LinkButton.cs index 7739e6e7f..3825c7ed5 100644 --- a/NetCord/Components/LinkButton.cs +++ b/NetCord/Components/LinkButton.cs @@ -2,7 +2,7 @@ namespace NetCord; -public class LinkButton : IButton, IJsonModel +public class LinkButton : ICustomizableButton, IJsonModel { JsonComponent IJsonModel.JsonModel => _jsonModel; private readonly JsonComponent _jsonModel; diff --git a/NetCord/Components/PremiumButton.cs b/NetCord/Components/PremiumButton.cs new file mode 100644 index 000000000..a89391be8 --- /dev/null +++ b/NetCord/Components/PremiumButton.cs @@ -0,0 +1,10 @@ +using NetCord.JsonModels; + +namespace NetCord; +public class PremiumButton(JsonComponent jsonModel) : IButton, IJsonModel +{ + JsonComponent IJsonModel.JsonModel => jsonModel; + + public ulong SkuId => jsonModel.SkuId.GetValueOrDefault(); + public bool Disabled => jsonModel.Disabled.GetValueOrDefault(); +} diff --git a/NetCord/CustomEmoji.cs b/NetCord/CustomEmoji.cs new file mode 100644 index 000000000..6bd4c6202 --- /dev/null +++ b/NetCord/CustomEmoji.cs @@ -0,0 +1,67 @@ +using NetCord.JsonModels; +using NetCord.Rest; + +namespace NetCord; + +public abstract class CustomEmoji : Emoji, ISpanFormattable +{ + private protected RestClient _client; + + public CustomEmoji(JsonEmoji jsonModel, RestClient client) : base(jsonModel) + { + _client = client; + + var creator = jsonModel.Creator; + if (creator is not null) + Creator = new(creator, client); + } + + public ulong Id => _jsonModel.Id.GetValueOrDefault(); + + public User? Creator { get; } + + public bool? RequireColons => _jsonModel.RequireColons; + + public bool? Managed => _jsonModel.Managed; + + public bool? Available => _jsonModel.Available; + + public override string ToString() => Animated ? $"" : $"<:{Name}:{Id}>"; + + public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); + + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format = default, IFormatProvider? provider = null) + { + var name = Name; + if (Animated) + { + if (destination.Length < 6 + name.Length || !Id.TryFormat(destination[(4 + name.Length)..^1], out int length)) + { + charsWritten = 0; + return false; + } + + "))] +[JsonConverter(typeof(JsonConverters.SafeStringEnumConverter))] public enum EmbedType { [JsonPropertyName("rich")] diff --git a/NetCord/Gateway/AuditLogEntry.cs b/NetCord/Gateway/AuditLogEntry.cs index 49b6ccc3c..549c96bee 100644 --- a/NetCord/Gateway/AuditLogEntry.cs +++ b/NetCord/Gateway/AuditLogEntry.cs @@ -12,14 +12,17 @@ public class AuditLogEntry : Entity, IJsonModel JsonAuditLogEntry IJsonModel.JsonModel => _jsonModel; private protected readonly JsonAuditLogEntry _jsonModel; - public AuditLogEntry(JsonAuditLogEntry jsonModel) + public AuditLogEntry(JsonAuditLogEntry jsonModel, ulong guildId) { _jsonModel = jsonModel; + Changes = _jsonModel.Changes.ToDictionaryOrEmpty(c => c.Key, c => new AuditLogChange(c)); var options = _jsonModel.Options; if (options is not null) Options = new(options); + + GuildId = guildId; } public override ulong Id => _jsonModel.Id; @@ -54,6 +57,11 @@ public AuditLogEntry(JsonAuditLogEntry jsonModel) /// public string? Reason => _jsonModel.Reason; + /// + /// The ID of the guild this audit log entry belongs to. + /// + public ulong GuildId { get; } + private bool TryGetChangeModel(Expression> expression, [NotNullWhen(true)] out JsonAuditLogChange model) { var member = GetMemberAccess(expression); diff --git a/NetCord/Gateway/EventArgs/GuildInviteDeleteEventArgs.cs b/NetCord/Gateway/EventArgs/GuildInviteDeleteEventArgs.cs deleted file mode 100644 index 630019e65..000000000 --- a/NetCord/Gateway/EventArgs/GuildInviteDeleteEventArgs.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace NetCord.Gateway; - -public class GuildInviteDeleteEventArgs(JsonModels.EventArgs.JsonGuildInviteDeleteEventArgs jsonModel) : IJsonModel -{ - JsonModels.EventArgs.JsonGuildInviteDeleteEventArgs IJsonModel.JsonModel => jsonModel; - - public ulong InviteChannelId => jsonModel.InviteChannelId; - - public ulong? GuildId => jsonModel.GuildId; - - public string InviteCode => jsonModel.InviteCode; -} diff --git a/NetCord/Gateway/EventArgs/InviteDeleteEventArgs.cs b/NetCord/Gateway/EventArgs/InviteDeleteEventArgs.cs new file mode 100644 index 000000000..82e7b3e94 --- /dev/null +++ b/NetCord/Gateway/EventArgs/InviteDeleteEventArgs.cs @@ -0,0 +1,12 @@ +namespace NetCord.Gateway; + +public class InviteDeleteEventArgs(JsonModels.EventArgs.JsonInviteDeleteEventArgs jsonModel) : IJsonModel +{ + JsonModels.EventArgs.JsonInviteDeleteEventArgs IJsonModel.JsonModel => jsonModel; + + public ulong InviteChannelId => jsonModel.InviteChannelId; + + public ulong? GuildId => jsonModel.GuildId; + + public string InviteCode => jsonModel.InviteCode; +} diff --git a/NetCord/Gateway/EventArgs/MessageReactionAddEventArgs.cs b/NetCord/Gateway/EventArgs/MessageReactionAddEventArgs.cs index e3b2522aa..747937164 100644 --- a/NetCord/Gateway/EventArgs/MessageReactionAddEventArgs.cs +++ b/NetCord/Gateway/EventArgs/MessageReactionAddEventArgs.cs @@ -31,4 +31,10 @@ public MessageReactionAddEventArgs(JsonModels.EventArgs.JsonMessageReactionAddEv public MessageReactionEmoji Emoji { get; } public ulong? MessageAuthorId => _jsonModel.MessageAuthorId; + + public bool Burst => _jsonModel.Burst; + + public IReadOnlyList BurstColors => _jsonModel.BurstColors; + + public ReactionType Type => _jsonModel.Type; } diff --git a/NetCord/Gateway/EventArgs/MessageReactionRemoveEventArgs.cs b/NetCord/Gateway/EventArgs/MessageReactionRemoveEventArgs.cs index e887f8cd8..689c958f0 100644 --- a/NetCord/Gateway/EventArgs/MessageReactionRemoveEventArgs.cs +++ b/NetCord/Gateway/EventArgs/MessageReactionRemoveEventArgs.cs @@ -13,4 +13,8 @@ public class MessageReactionRemoveEventArgs(JsonModels.EventArgs.JsonMessageReac public ulong? GuildId => jsonModel.GuildId; public MessageReactionEmoji Emoji { get; } = new(jsonModel.Emoji); + + public bool Burst => jsonModel.Burst; + + public ReactionType Type => jsonModel.Type; } diff --git a/NetCord/Gateway/GatewayClient.cs b/NetCord/Gateway/GatewayClient.cs index 6cd367ff6..77b379fb2 100644 --- a/NetCord/Gateway/GatewayClient.cs +++ b/NetCord/Gateway/GatewayClient.cs @@ -489,7 +489,7 @@ public partial class GatewayClient : WebSocketClient, IEntity ///
Required Intents: ///
Optional Intents: None /// - public event Func? GuildInviteCreate; + public event Func? InviteCreate; /// /// Sent when an invite is deleted. Only sent if the bot has the permission for the relevant channel.
@@ -498,7 +498,7 @@ public partial class GatewayClient : WebSocketClient, IEntity ///
Required Intents: ///
Optional Intents: None /// - public event Func? GuildInviteDelete; + public event Func? InviteDelete; /// /// Sent when a message is created. @@ -547,7 +547,7 @@ public partial class GatewayClient : WebSocketClient, IEntity /// /// Sent when a message is updated. - /// The inner payload is a partial message object, with only the message's ID and Guild ID being guaranteed present, all other fields can be null.
+ /// The inner payload is a message object with set , and fields.
///
/// ///
Required Intents: , * @@ -588,7 +588,7 @@ public partial class GatewayClient : WebSocketClient, IEntity ///

/// *Ephemeral messages do not use the guild channel. Because of this, they are tied to the intent, and the message object won't include a or . ///
- public event Func? MessageUpdate; + public event Func? MessageUpdate; /// /// Sent when a message is deleted.
@@ -1130,7 +1130,7 @@ await InvokeEventAsync(Ready, args, data => break; case "GUILD_AUDIT_LOG_ENTRY_CREATE": { - await InvokeEventAsync(GuildAuditLogEntryCreate, () => new(data.ToObject(Serialization.Default.JsonAuditLogEntry))).ConfigureAwait(false); + await InvokeEventAsync(GuildAuditLogEntryCreate, () => new(data.ToObject(Serialization.Default.JsonAuditLogEntry), GetGuildId())).ConfigureAwait(false); } break; case "GUILD_BAN_ADD": @@ -1260,12 +1260,12 @@ await InvokeEventAsync(Ready, args, data => break; case "INVITE_CREATE": { - await InvokeEventAsync(GuildInviteCreate, () => new(data.ToObject(Serialization.Default.JsonGuildInvite), Rest)).ConfigureAwait(false); + await InvokeEventAsync(InviteCreate, () => new(data.ToObject(Serialization.Default.JsonInvite), Rest)).ConfigureAwait(false); } break; case "INVITE_DELETE": { - await InvokeEventAsync(GuildInviteDelete, () => new(data.ToObject(Serialization.Default.JsonGuildInviteDeleteEventArgs))).ConfigureAwait(false); + await InvokeEventAsync(InviteDelete, () => new(data.ToObject(Serialization.Default.JsonInviteDeleteEventArgs))).ConfigureAwait(false); } break; case "MESSAGE_CREATE": @@ -1290,7 +1290,7 @@ await InvokeEventAsync( await InvokeEventAsync( MessageUpdate, () => data.ToObject(Serialization.Default.JsonMessage), - json => IPartialMessage.CreateFromJson(json, Cache, Rest), + json => Message.CreateFromJson(json, Cache, Rest), json => _configuration.CacheDMChannels && !json.GuildId.HasValue && !json.Flags.GetValueOrDefault().HasFlag(MessageFlags.Ephemeral), json => { diff --git a/NetCord/Gateway/GatewayIntents.cs b/NetCord/Gateway/GatewayIntents.cs index 28bc52818..8eec41b5d 100644 --- a/NetCord/Gateway/GatewayIntents.cs +++ b/NetCord/Gateway/GatewayIntents.cs @@ -75,7 +75,7 @@ public enum GatewayIntents : uint /// /// Associated with the following events:
- /// , + /// , ///
GuildInvites = 1 << 6, diff --git a/NetCord/Gateway/GuildJoinRequestFormResponseFieldType.cs b/NetCord/Gateway/GuildJoinRequestFormResponseFieldType.cs index 0cf645ae2..308a0f194 100644 --- a/NetCord/Gateway/GuildJoinRequestFormResponseFieldType.cs +++ b/NetCord/Gateway/GuildJoinRequestFormResponseFieldType.cs @@ -2,7 +2,7 @@ namespace NetCord.Gateway; -[JsonConverter(typeof(JsonConverters.StringEnumConverterWithErrorHandling))] +[JsonConverter(typeof(JsonConverters.SafeStringEnumConverter))] public enum GuildJoinRequestFormResponseFieldType { [JsonPropertyName("TERMS")] diff --git a/NetCord/Gateway/GuildJoinRequestStatus.cs b/NetCord/Gateway/GuildJoinRequestStatus.cs index 39e8e3bf8..f62f29418 100644 --- a/NetCord/Gateway/GuildJoinRequestStatus.cs +++ b/NetCord/Gateway/GuildJoinRequestStatus.cs @@ -2,7 +2,7 @@ namespace NetCord.Gateway; -[JsonConverter(typeof(JsonConverters.StringEnumConverterWithErrorHandling))] +[JsonConverter(typeof(JsonConverters.SafeStringEnumConverter))] public enum GuildJoinRequestStatus { [JsonPropertyName("STARTED")] diff --git a/NetCord/Gateway/IPartialMessage.cs b/NetCord/Gateway/IPartialMessage.cs deleted file mode 100644 index 1ed7ff111..000000000 --- a/NetCord/Gateway/IPartialMessage.cs +++ /dev/null @@ -1,339 +0,0 @@ -using NetCord.JsonModels; -using NetCord.Rest; - -namespace NetCord.Gateway; - -/// -/// Represents an incomplete object, with missing fields. Sent during events. -/// -public partial interface IPartialMessage : IEntity -{ - public static IPartialMessage CreateFromJson(JsonMessage jsonModel, IGatewayClientCache cache, RestClient client) - { - if (jsonModel.Content is null || jsonModel.Author is null) - { - var (guild, channel) = GetCacheData(jsonModel, cache); - return new PartialMessage(jsonModel, guild, channel, client); - } - - return Message.CreateFromJson(jsonModel, cache, client); - } - - internal static (Guild?, TextChannel?) GetCacheData(JsonMessage jsonModel, IGatewayClientCache cache) - { - Guild? guild; - TextChannel? channel; - var guildId = jsonModel.GuildId; - if (guildId.HasValue) - { - if (cache.Guilds.TryGetValue(guildId.GetValueOrDefault(), out guild)) - { - var channelId = jsonModel.ChannelId; - if (guild.Channels.TryGetValue(channelId, out var guildChannel)) - channel = (TextChannel)guildChannel; - else if (guild.ActiveThreads.TryGetValue(channelId, out var thread)) - channel = thread; - else - channel = null; - } - else - channel = null; - } - else - { - guild = null; - channel = cache.DMChannels.GetValueOrDefault(jsonModel.ChannelId); - } - - return (guild, channel); - } - - /// - /// The ID of the the message belongs to. - /// - public ulong? GuildId { get; } - - /// - /// The the message belongs to. - /// - public Guild? Guild { get; } - - /// - /// The the message was sent in. - /// - public TextChannel? Channel { get; } - - /// - public ulong ChannelId { get; } - - /// - public User? Author { get; } - - /// - public string? Content { get; } - - /// - public DateTimeOffset? EditedAt { get; } - - /// - public bool? IsTts { get; } - - /// - public bool? MentionEveryone { get; } - - /// - public IReadOnlyDictionary? MentionedUsers { get; } - - /// - public IReadOnlyList? MentionedRoleIds { get; } - - /// - public IReadOnlyDictionary? MentionedChannels { get; } - - /// - public IReadOnlyDictionary? Attachments { get; } - - /// - public IReadOnlyList? Embeds { get; } - - /// - public IReadOnlyList? Reactions { get; } - - /// - public string? Nonce { get; } - - /// - public bool? IsPinned { get; } - - /// - public ulong? WebhookId { get; } - - /// - public MessageType? Type { get; } - - /// - public MessageActivity? Activity { get; } - - /// - public Application? Application { get; } - - /// - public ulong? ApplicationId { get; } - - /// - public MessageReference? MessageReference { get; } - - /// - public MessageFlags? Flags { get; } - - /// - public RestMessage? ReferencedMessage { get; } - - /// - public MessageInteractionMetadata? InteractionMetadata { get; } - - /// - [Obsolete($"Replaced by '{nameof(InteractionMetadata)}'")] - public MessageInteraction? Interaction { get; } - - /// - public GuildThread? StartedThread { get; } - - /// - public IReadOnlyList? Components { get; } - - /// - public IReadOnlyDictionary? Stickers { get; } - - /// - public int? Position { get; } - - /// - public RoleSubscriptionData? RoleSubscriptionData { get; } - - /// - public InteractionResolvedData? ResolvedData { get; } - - public MessagePoll? Poll { get; } - - /// - public Task ReplyAsync(ReplyMessageProperties replyMessage, RestRequestProperties? properties = null); -} - -internal partial class PartialMessage : ClientEntity, IPartialMessage, IJsonModel -{ - private readonly JsonMessage _jsonModel; - JsonMessage IJsonModel.JsonModel => _jsonModel; - - public PartialMessage(JsonMessage jsonModel, Guild? guild, TextChannel? channel, RestClient client) : base(client) - { - _jsonModel = jsonModel; - - Guild = guild; - Channel = channel; - - var author = jsonModel.Author; - if (author is not null) - { - var guildUser = jsonModel.GuildUser; - if (guildUser is null) - Author = new(jsonModel.Author!, client); - else - { - guildUser.User = jsonModel.Author!; - Author = new GuildUser(guildUser, jsonModel.GuildId.GetValueOrDefault(), client); - } - } - - var mentionedUsers = jsonModel.MentionedUsers; - if (mentionedUsers is not null) - MentionedUsers = mentionedUsers.ToDictionary(u => u.Id, u => - { - var guildUser = u.GuildUser; - if (guildUser is null) - return new User(u, client); - - guildUser.User = u; - return new GuildUser(guildUser, jsonModel.GuildId.GetValueOrDefault(), client); - }); - - var mentionedChannels = jsonModel.MentionedChannels; - if (mentionedChannels is not null) - MentionedChannels = mentionedChannels.ToDictionary(c => c.Id, c => new GuildChannelMention(c)); - - var attachments = jsonModel.Attachments; - if (attachments is not null) - Attachments = attachments.ToDictionary(a => a.Id, Attachment.CreateFromJson); - - var embeds = jsonModel.Embeds; - if (embeds is not null) - Embeds = embeds.Select(e => new Embed(e)).ToArray(); - - var reactions = jsonModel.Reactions; - if (reactions is not null) - Reactions = reactions.Select(r => new MessageReaction(r)).ToArray(); - - var activity = jsonModel.Activity; - if (activity is not null) - Activity = new(activity); - - var application = jsonModel.Application; - if (application is not null) - Application = new(application, client); - - var messageReference = jsonModel.MessageReference; - if (messageReference is not null) - MessageReference = new(messageReference); - - var referencedMessage = jsonModel.ReferencedMessage; - if (referencedMessage is not null) - ReferencedMessage = new(referencedMessage, client); - - var interactionMetadata = jsonModel.InteractionMetadata; - if (interactionMetadata is not null) - InteractionMetadata = new(interactionMetadata, client); - -#pragma warning disable CS0618 // Type or member is obsolete - var interaction = jsonModel.Interaction; - if (interaction is not null) - Interaction = new(interaction, client); -#pragma warning restore CS0618 // Type or member is obsolete - - var startedThread = jsonModel.StartedThread; - if (startedThread is not null) - StartedThread = GuildThread.CreateFromJson(startedThread, client); - - var components = jsonModel.Components; - if (components is not null) - Components = components.Select(IMessageComponent.CreateFromJson).ToArray(); - - var stickers = jsonModel.Stickers; - if (stickers is not null) - Stickers = stickers.ToDictionary(s => s.Id, s => new MessageSticker(s, client)); - - var roleSubscriptionData = jsonModel.RoleSubscriptionData; - if (roleSubscriptionData is not null) - RoleSubscriptionData = new(roleSubscriptionData); - - var resolvedData = jsonModel.ResolvedData; - if (resolvedData is not null) - ResolvedData = new(resolvedData, jsonModel.GuildId, client); - - var poll = jsonModel.Poll; - if (poll is not null) - Poll = new(poll); - } - - public override ulong Id => _jsonModel.Id; - - public ulong? GuildId => _jsonModel.GuildId; - - public Guild? Guild { get; } - - public ulong ChannelId => _jsonModel.ChannelId; - - public TextChannel? Channel { get; } - - public User? Author { get; } - - public string? Content => _jsonModel.Content; - - public DateTimeOffset? EditedAt => _jsonModel.EditedAt; - - public bool? IsTts => _jsonModel.IsTts; - - public bool? MentionEveryone => _jsonModel.MentionEveryone; - - public IReadOnlyDictionary? MentionedUsers { get; } - - public IReadOnlyList? MentionedRoleIds => _jsonModel.MentionedRoleIds; - - public IReadOnlyDictionary? MentionedChannels { get; } - - public IReadOnlyDictionary? Attachments { get; } - - public IReadOnlyList? Embeds { get; } - - public IReadOnlyList? Reactions { get; } - - public string? Nonce => _jsonModel.Nonce; - - public bool? IsPinned => _jsonModel.IsPinned; - - public ulong? WebhookId => _jsonModel.WebhookId; - - public MessageType? Type => _jsonModel.Type; - - public MessageActivity? Activity { get; } - - public Application? Application { get; } - - public ulong? ApplicationId => _jsonModel.ApplicationId; - - public MessageReference? MessageReference { get; } - - public MessageFlags? Flags => _jsonModel.Flags; - - public RestMessage? ReferencedMessage { get; } - - public MessageInteractionMetadata? InteractionMetadata { get; } - - public MessageInteraction? Interaction { get; } - - public GuildThread? StartedThread { get; } - - public IReadOnlyList? Components { get; } - - public IReadOnlyDictionary? Stickers { get; } - - public int? Position => _jsonModel.Position; - - public RoleSubscriptionData? RoleSubscriptionData { get; } - - public InteractionResolvedData? ResolvedData { get; } - - public MessagePoll? Poll { get; } - - public Task ReplyAsync(ReplyMessageProperties replyMessage, RestRequestProperties? properties = null) - => SendAsync(replyMessage.ToMessageProperties(Id), properties); -} diff --git a/NetCord/Gateway/GuildInvite.cs b/NetCord/Gateway/Invite.cs similarity index 61% rename from NetCord/Gateway/GuildInvite.cs rename to NetCord/Gateway/Invite.cs index eb3b553b2..c3da9d678 100644 --- a/NetCord/Gateway/GuildInvite.cs +++ b/NetCord/Gateway/Invite.cs @@ -2,12 +2,12 @@ namespace NetCord.Gateway; -public class GuildInvite : IGuildInvite, IJsonModel +public class Invite : IInvite, IJsonModel { - JsonModels.JsonGuildInvite IJsonModel.JsonModel => _jsonModel; - private readonly JsonModels.JsonGuildInvite _jsonModel; + JsonModels.JsonInvite IJsonModel.JsonModel => _jsonModel; + private readonly JsonModels.JsonInvite _jsonModel; - public GuildInvite(JsonModels.JsonGuildInvite jsonModel, RestClient client) + public Invite(JsonModels.JsonInvite jsonModel, RestClient client) { _jsonModel = jsonModel; @@ -24,6 +24,8 @@ public GuildInvite(JsonModels.JsonGuildInvite jsonModel, RestClient client) TargetApplication = new(targetApplication, client); } + public InviteType Type => _jsonModel.Type; + public ulong ChannelId => _jsonModel.ChannelId; public string Code => _jsonModel.Code; @@ -38,7 +40,7 @@ public GuildInvite(JsonModels.JsonGuildInvite jsonModel, RestClient client) public int MaxUses => _jsonModel.MaxUses; - public GuildInviteTargetType? TargetType => _jsonModel.TargetType; + public InviteTargetType? TargetType => _jsonModel.TargetType; public User? TargetUser { get; } @@ -48,15 +50,15 @@ public GuildInvite(JsonModels.JsonGuildInvite jsonModel, RestClient client) public int Uses => _jsonModel.Uses; - ulong? IGuildInvite.ChannelId => ChannelId; + ulong? IInvite.ChannelId => ChannelId; - int? IGuildInvite.MaxAge => MaxAge; + int? IInvite.MaxAge => MaxAge; - int? IGuildInvite.MaxUses => MaxUses; + int? IInvite.MaxUses => MaxUses; - bool? IGuildInvite.Temporary => Temporary; + bool? IInvite.Temporary => Temporary; - int? IGuildInvite.Uses => Uses; + int? IInvite.Uses => Uses; - DateTimeOffset? IGuildInvite.CreatedAt => CreatedAt; + DateTimeOffset? IInvite.CreatedAt => CreatedAt; } diff --git a/NetCord/Gateway/JsonModels/EventArgs/JsonGuildInviteDeleteEventArgs.cs b/NetCord/Gateway/JsonModels/EventArgs/JsonInviteDeleteEventArgs.cs similarity index 88% rename from NetCord/Gateway/JsonModels/EventArgs/JsonGuildInviteDeleteEventArgs.cs rename to NetCord/Gateway/JsonModels/EventArgs/JsonInviteDeleteEventArgs.cs index 5a762d330..d3c6b4a76 100644 --- a/NetCord/Gateway/JsonModels/EventArgs/JsonGuildInviteDeleteEventArgs.cs +++ b/NetCord/Gateway/JsonModels/EventArgs/JsonInviteDeleteEventArgs.cs @@ -2,7 +2,7 @@ namespace NetCord.Gateway.JsonModels.EventArgs; -public class JsonGuildInviteDeleteEventArgs +public class JsonInviteDeleteEventArgs { [JsonPropertyName("channel_id")] public ulong InviteChannelId { get; set; } diff --git a/NetCord/Gateway/JsonModels/EventArgs/JsonMessageReactionAddEventArgs.cs b/NetCord/Gateway/JsonModels/EventArgs/JsonMessageReactionAddEventArgs.cs index 6f4ca1b84..204c3f576 100644 --- a/NetCord/Gateway/JsonModels/EventArgs/JsonMessageReactionAddEventArgs.cs +++ b/NetCord/Gateway/JsonModels/EventArgs/JsonMessageReactionAddEventArgs.cs @@ -26,4 +26,13 @@ public class JsonMessageReactionAddEventArgs [JsonPropertyName("message_author_id")] public ulong? MessageAuthorId { get; set; } + + [JsonPropertyName("burst")] + public bool Burst { get; set; } + + [JsonPropertyName("burst_colors")] + public Color[] BurstColors { get; set; } + + [JsonPropertyName("type")] + public ReactionType Type { get; set; } } diff --git a/NetCord/Gateway/JsonModels/EventArgs/JsonMessageReactionRemoveEventArgs.cs b/NetCord/Gateway/JsonModels/EventArgs/JsonMessageReactionRemoveEventArgs.cs index a5693081c..9351a1bc8 100644 --- a/NetCord/Gateway/JsonModels/EventArgs/JsonMessageReactionRemoveEventArgs.cs +++ b/NetCord/Gateway/JsonModels/EventArgs/JsonMessageReactionRemoveEventArgs.cs @@ -20,4 +20,10 @@ public class JsonMessageReactionRemoveEventArgs [JsonPropertyName("emoji")] public JsonEmoji Emoji { get; set; } + + [JsonPropertyName("burst")] + public bool Burst { get; set; } + + [JsonPropertyName("type")] + public ReactionType Type { get; set; } } diff --git a/NetCord/Gateway/JsonModels/JsonGuildInvite.cs b/NetCord/Gateway/JsonModels/JsonInvite.cs similarity index 87% rename from NetCord/Gateway/JsonModels/JsonGuildInvite.cs rename to NetCord/Gateway/JsonModels/JsonInvite.cs index b6baaad03..ec00d630b 100644 --- a/NetCord/Gateway/JsonModels/JsonGuildInvite.cs +++ b/NetCord/Gateway/JsonModels/JsonInvite.cs @@ -4,8 +4,11 @@ namespace NetCord.Gateway.JsonModels; -public class JsonGuildInvite +public class JsonInvite { + [JsonPropertyName("type")] + public InviteType Type { get; set; } + [JsonPropertyName("channel_id")] public ulong ChannelId { get; set; } @@ -28,7 +31,7 @@ public class JsonGuildInvite public int MaxUses { get; set; } [JsonPropertyName("target_type")] - public GuildInviteTargetType? TargetType { get; set; } + public InviteTargetType? TargetType { get; set; } [JsonPropertyName("target_user")] public JsonUser? TargetUser { get; set; } diff --git a/NetCord/Gateway/Message.cs b/NetCord/Gateway/Message.cs index 5652970d6..6006a3a78 100644 --- a/NetCord/Gateway/Message.cs +++ b/NetCord/Gateway/Message.cs @@ -6,14 +6,43 @@ namespace NetCord.Gateway; /// /// Represents a complete object, with all required fields present. /// -public class Message(JsonMessage jsonModel, Guild? guild, TextChannel? channel, RestClient client) : RestMessage(jsonModel, client), IPartialMessage +public class Message(JsonMessage jsonModel, Guild? guild, TextChannel? channel, RestClient client) : RestMessage(jsonModel, client) { public static Message CreateFromJson(JsonMessage jsonModel, IGatewayClientCache cache, RestClient client) { - var (guild, channel) = IPartialMessage.GetCacheData(jsonModel, cache); + var (guild, channel) = GetCacheData(jsonModel, cache); return new(jsonModel, guild, channel, client); } + internal static (Guild?, TextChannel?) GetCacheData(JsonMessage jsonModel, IGatewayClientCache cache) + { + Guild? guild; + TextChannel? channel; + var guildId = jsonModel.GuildId; + if (guildId.HasValue) + { + if (cache.Guilds.TryGetValue(guildId.GetValueOrDefault(), out guild)) + { + var channelId = jsonModel.ChannelId; + if (guild.Channels.TryGetValue(channelId, out var guildChannel)) + channel = (TextChannel)guildChannel; + else if (guild.ActiveThreads.TryGetValue(channelId, out var thread)) + channel = thread; + else + channel = null; + } + else + channel = null; + } + else + { + guild = null; + channel = cache.DMChannels.GetValueOrDefault(jsonModel.ChannelId); + } + + return (guild, channel); + } + /// public ulong? GuildId => _jsonModel.GuildId; @@ -22,19 +51,4 @@ public static Message CreateFromJson(JsonMessage jsonModel, IGatewayClientCache /// public TextChannel? Channel { get; } = channel; - - /// - bool? IPartialMessage.IsTts => IsTts; - - /// - bool? IPartialMessage.MentionEveryone => MentionEveryone; - - /// - bool? IPartialMessage.IsPinned => IsPinned; - - /// - MessageType? IPartialMessage.Type => Type; - - /// - MessageFlags? IPartialMessage.Flags => Flags; } diff --git a/NetCord/Gateway/Platform.cs b/NetCord/Gateway/Platform.cs index d3d5dd971..13aef7c1a 100644 --- a/NetCord/Gateway/Platform.cs +++ b/NetCord/Gateway/Platform.cs @@ -2,7 +2,7 @@ namespace NetCord.Gateway; -[JsonConverter(typeof(JsonConverters.StringEnumConverterWithErrorHandling))] +[JsonConverter(typeof(JsonConverters.SafeStringEnumConverter))] public enum Platform { [JsonPropertyName("desktop")] diff --git a/NetCord/Gateway/ShardedGatewayClient.cs b/NetCord/Gateway/ShardedGatewayClient.cs index e06cca89d..087c3244f 100644 --- a/NetCord/Gateway/ShardedGatewayClient.cs +++ b/NetCord/Gateway/ShardedGatewayClient.cs @@ -305,8 +305,8 @@ private void HookEvents(GatewayClient client) HookEvent(client, _guildIntegrationCreateLock, ref _guildIntegrationCreate, a => _guildIntegrationCreate!(client, a), (c, e) => c.GuildIntegrationCreate += e); HookEvent(client, _guildIntegrationUpdateLock, ref _guildIntegrationUpdate, a => _guildIntegrationUpdate!(client, a), (c, e) => c.GuildIntegrationUpdate += e); HookEvent(client, _guildIntegrationDeleteLock, ref _guildIntegrationDelete, a => _guildIntegrationDelete!(client, a), (c, e) => c.GuildIntegrationDelete += e); - HookEvent(client, _guildInviteCreateLock, ref _guildInviteCreate, a => _guildInviteCreate!(client, a), (c, e) => c.GuildInviteCreate += e); - HookEvent(client, _guildInviteDeleteLock, ref _guildInviteDelete, a => _guildInviteDelete!(client, a), (c, e) => c.GuildInviteDelete += e); + HookEvent(client, _inviteCreateLock, ref _inviteCreate, a => _inviteCreate!(client, a), (c, e) => c.InviteCreate += e); + HookEvent(client, _inviteDeleteLock, ref _inviteDelete, a => _inviteDelete!(client, a), (c, e) => c.InviteDelete += e); HookEvent(client, _messageCreateLock, ref _messageCreate, a => _messageCreate!(client, a), (c, e) => c.MessageCreate += e); HookEvent(client, _messageUpdateLock, ref _messageUpdate, a => _messageUpdate!(client, a), (c, e) => c.MessageUpdate += e); HookEvent(client, _messageDeleteLock, ref _messageDelete, a => _messageDelete!(client, a), (c, e) => c.MessageDelete += e); @@ -1035,35 +1035,35 @@ public event Func? Gu private Func? _guildIntegrationDelete; private readonly object _guildIntegrationDeleteLock = new(); - /// - public event Func? GuildInviteCreate + /// + public event Func? InviteCreate { add { - HookEvent(_guildInviteCreateLock, value, ref _guildInviteCreate, client => a => _guildInviteCreate!(client, a), (c, e) => c.GuildInviteCreate += e); + HookEvent(_inviteCreateLock, value, ref _inviteCreate, client => a => _inviteCreate!(client, a), (c, e) => c.InviteCreate += e); } remove { - UnhookEvent(_guildInviteCreateLock, value, ref _guildInviteCreate, (c, e) => c.GuildInviteCreate -= e); + UnhookEvent(_inviteCreateLock, value, ref _inviteCreate, (c, e) => c.InviteCreate -= e); } } - private Func? _guildInviteCreate; - private readonly object _guildInviteCreateLock = new(); + private Func? _inviteCreate; + private readonly object _inviteCreateLock = new(); - /// - public event Func? GuildInviteDelete + /// + public event Func? InviteDelete { add { - HookEvent(_guildInviteDeleteLock, value, ref _guildInviteDelete, client => a => _guildInviteDelete!(client, a), (c, e) => c.GuildInviteDelete += e); + HookEvent(_inviteDeleteLock, value, ref _inviteDelete, client => a => _inviteDelete!(client, a), (c, e) => c.InviteDelete += e); } remove { - UnhookEvent(_guildInviteDeleteLock, value, ref _guildInviteDelete, (c, e) => c.GuildInviteDelete -= e); + UnhookEvent(_inviteDeleteLock, value, ref _inviteDelete, (c, e) => c.InviteDelete -= e); } } - private Func? _guildInviteDelete; - private readonly object _guildInviteDeleteLock = new(); + private Func? _inviteDelete; + private readonly object _inviteDeleteLock = new(); /// public event Func? MessageCreate @@ -1081,7 +1081,7 @@ public event Func? MessageCreate private readonly object _messageCreateLock = new(); /// - public event Func? MessageUpdate + public event Func? MessageUpdate { add { @@ -1092,7 +1092,7 @@ public event Func? MessageUpdate UnhookEvent(_messageUpdateLock, value, ref _messageUpdate, (c, e) => c.MessageUpdate -= e); } } - private Func? _messageUpdate; + private Func? _messageUpdate; private readonly object _messageUpdateLock = new(); /// diff --git a/NetCord/GuildEmoji.cs b/NetCord/GuildEmoji.cs index ae4f9b5ae..a613a7017 100644 --- a/NetCord/GuildEmoji.cs +++ b/NetCord/GuildEmoji.cs @@ -3,71 +3,9 @@ namespace NetCord; -public partial class GuildEmoji : Emoji, ISpanFormattable +public partial class GuildEmoji(JsonEmoji jsonModel, ulong guildId, RestClient client) : CustomEmoji(jsonModel, client) { - private readonly RestClient _client; - - public GuildEmoji(JsonEmoji jsonModel, ulong guildId, RestClient client) : base(jsonModel) - { - _client = client; - - var creator = jsonModel.Creator; - if (creator is not null) - Creator = new(creator, client); - - GuildId = guildId; - } - - public ulong Id => _jsonModel.Id.GetValueOrDefault(); - public IReadOnlyList? AllowedRoles => _jsonModel.AllowedRoles; - public User? Creator { get; } - - public bool? RequireColons => _jsonModel.RequireColons; - - public bool? Managed => _jsonModel.Managed; - - public bool? Available => _jsonModel.Available; - - public ulong GuildId { get; } - - public override string ToString() => Animated ? $"" : $"<:{Name}:{Id}>"; - - public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); - - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format = default, IFormatProvider? provider = null) - { - var name = Name; - if (Animated) - { - if (destination.Length < 6 + name.Length || !Id.TryFormat(destination[(4 + name.Length)..^1], out int length)) - { - charsWritten = 0; - return false; - } - - "An pointing to the user's guild avatar. If the user does not have one set, returns . public ImageUrl? GetGuildAvatarUrl(ImageFormat? format = null) => GuildAvatarHash is string hash ? ImageUrl.GuildUserAvatar(GuildId, Id, hash, format) : null; + /// + /// Gets the of the user's guild avatar decoration. + /// + /// An pointing to the user's guild avatar decoration. If the user does not have one set, returns . + public ImageUrl? GetGuildAvatarDecorationUrl() => GuildAvatarDecorationData is { Hash: var hash } ? ImageUrl.AvatarDecoration(hash) : null; + /// /// Applies a timeout to the for a specified . /// diff --git a/NetCord/IGuildInvite.cs b/NetCord/IInvite.cs similarity index 79% rename from NetCord/IGuildInvite.cs rename to NetCord/IInvite.cs index b13862c0e..41b91a6f1 100644 --- a/NetCord/IGuildInvite.cs +++ b/NetCord/IInvite.cs @@ -1,7 +1,8 @@ namespace NetCord; -public interface IGuildInvite +public interface IInvite { + public InviteType Type { get; } public ulong? GuildId { get; } public ulong? ChannelId { get; } public string Code { get; } @@ -10,7 +11,7 @@ public interface IGuildInvite public Application? TargetApplication { get; } public int? MaxAge { get; } public int? MaxUses { get; } - public GuildInviteTargetType? TargetType { get; } + public InviteTargetType? TargetType { get; } public bool? Temporary { get; } public int? Uses { get; } public DateTimeOffset? CreatedAt { get; } diff --git a/NetCord/ImageUrl.cs b/NetCord/ImageUrl.cs index 3ad5a00e2..77f0542a0 100644 --- a/NetCord/ImageUrl.cs +++ b/NetCord/ImageUrl.cs @@ -157,9 +157,9 @@ public static ImageUrl GuildUserAvatar(ulong guildId, ulong userId, string avata return new($"/guilds/{guildId}/users/{userId}/avatars/{avatarHash}", GetExtension(avatarHash, format)); } - public static ImageUrl UserAvatarDecoration(ulong userId, string avatarDecorationHash) + public static ImageUrl AvatarDecoration(string avatarDecorationHash) { - return new($"/avatar-decorations/{userId}/{avatarDecorationHash}", "png"); + return new($"/avatar-decoration-presets/{avatarDecorationHash}", "png"); } public static ImageUrl ApplicationIcon(ulong applicationId, string iconHash, ImageFormat format) diff --git a/NetCord/IntegrationType.cs b/NetCord/IntegrationType.cs index 6d635f342..ddbde53f5 100644 --- a/NetCord/IntegrationType.cs +++ b/NetCord/IntegrationType.cs @@ -2,7 +2,7 @@ namespace NetCord; -[JsonConverter(typeof(JsonConverters.StringEnumConverterWithErrorHandling))] +[JsonConverter(typeof(JsonConverters.SafeStringEnumConverter))] public enum IntegrationType { [JsonPropertyName("twitch")] diff --git a/NetCord/Interaction.cs b/NetCord/Interaction.cs index 01ebdd4e1..d2bb84404 100644 --- a/NetCord/Interaction.cs +++ b/NetCord/Interaction.cs @@ -20,6 +20,10 @@ private protected Interaction(JsonModels.JsonInteraction jsonModel, Guild? guild else User = new(jsonModel.User!, client); + var guildReference = jsonModel.GuildReference; + if (guildReference is not null) + GuildReference = new(guildReference); + Guild = guild; Channel = TextChannel.CreateFromJson(jsonModel.Channel!, client); Entitlements = jsonModel.Entitlements.Select(e => new Entitlement(e)).ToArray(); @@ -33,6 +37,8 @@ private protected Interaction(JsonModels.JsonInteraction jsonModel, Guild? guild public ulong? GuildId => _jsonModel.GuildId; + public InteractionGuildReference? GuildReference { get; } + public Guild? Guild { get; } public TextChannel Channel { get; } diff --git a/NetCord/InteractionGuildReference.cs b/NetCord/InteractionGuildReference.cs new file mode 100644 index 000000000..f76c8d643 --- /dev/null +++ b/NetCord/InteractionGuildReference.cs @@ -0,0 +1,14 @@ +using NetCord.JsonModels; + +namespace NetCord; + +public class InteractionGuildReference(JsonInteractionGuildReference jsonModel) : Entity, IJsonModel +{ + JsonInteractionGuildReference IJsonModel.JsonModel => jsonModel; + + public override ulong Id => jsonModel.Id; + + public string[] Features => jsonModel.Features; + + public string Locale => jsonModel.Locale; +} diff --git a/NetCord/GuildInviteTargetType.cs b/NetCord/InviteTargetType.cs similarity index 67% rename from NetCord/GuildInviteTargetType.cs rename to NetCord/InviteTargetType.cs index c53465ef1..f1d6fb607 100644 --- a/NetCord/GuildInviteTargetType.cs +++ b/NetCord/InviteTargetType.cs @@ -1,6 +1,6 @@ namespace NetCord; -public enum GuildInviteTargetType +public enum InviteTargetType { Stream = 1, EmbeddedApplication = 2, diff --git a/NetCord/InviteType.cs b/NetCord/InviteType.cs new file mode 100644 index 000000000..c4365f46e --- /dev/null +++ b/NetCord/InviteType.cs @@ -0,0 +1,8 @@ +namespace NetCord; + +public enum InviteType : byte +{ + Guild = 0, + GroupDMChannel = 1, + Friend = 2, +} diff --git a/NetCord/JsonConverters/AttachmentPropertiesIEnumerableConverter.cs b/NetCord/JsonConverters/AttachmentPropertiesIEnumerableConverter.cs index f75040e4f..342df3bea 100644 --- a/NetCord/JsonConverters/AttachmentPropertiesIEnumerableConverter.cs +++ b/NetCord/JsonConverters/AttachmentPropertiesIEnumerableConverter.cs @@ -9,6 +9,7 @@ public class AttachmentPropertiesIEnumerableConverter : JsonConverter : JsonConverter where T : struct, Enum +public class SafeStringEnumConverter : JsonConverter where T : struct, Enum { - private static readonly JsonEncodedText _unknownName = JsonEncodedText.Encode(default(ReadOnlySpan)); private static readonly T _unknownValue = (T)(object)-1; - private readonly Dictionary, T> _namesDictionary; - private readonly Dictionary _valuesDictionary; + private readonly FrozenDictionary, T> _namesDictionary; + private readonly FrozenDictionary _valuesDictionary; [UnconditionalSuppressMessage("Trimming", "IL2090:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The generic parameter of the source method or type does not have matching annotations.", Justification = "Literal fields on enums can never be trimmed")] - public StringEnumConverterWithErrorHandling() + public SafeStringEnumConverter() { var enumType = typeof(T); var fields = enumType.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); int length = fields.Length; - Dictionary, T> namesDictionary = new(length, OrdinalReadOnlyMemoryByteComparer.Instance); - Dictionary valuesDictionary = new(length); + var names = new KeyValuePair, T>[length]; + var values = new KeyValuePair[length]; for (var i = 0; i < length; i++) { @@ -37,12 +37,12 @@ public StringEnumConverterWithErrorHandling() var rawValue = field.GetRawConstantValue()!; var value = (T)rawValue; - namesDictionary.Add(nameBytes, value); - valuesDictionary.Add(value, JsonEncodedText.Encode(nameBytes)); + names[i] = new(nameBytes, value); + values[i] = new(value, JsonEncodedText.Encode(nameBytes)); } - _namesDictionary = namesDictionary; - _valuesDictionary = valuesDictionary; + _namesDictionary = names.ToFrozenDictionary(SafeStringEnumConverter.OrdinalReadOnlyMemoryByteComparer.Instance); + _valuesDictionary = values.ToFrozenDictionary(); } public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -61,15 +61,20 @@ public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConve public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - writer.WriteStringValue(_valuesDictionary.TryGetValue(value, out var name) ? name : _unknownName); + writer.WriteStringValue(_valuesDictionary.TryGetValue(value, out var name) ? name : SafeStringEnumConverter._unknownName); } public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - writer.WritePropertyName(_valuesDictionary.TryGetValue(value, out var name) ? name : _unknownName); + writer.WritePropertyName(_valuesDictionary.TryGetValue(value, out var name) ? name : SafeStringEnumConverter._unknownName); } +} + +static file class SafeStringEnumConverter +{ + internal static readonly JsonEncodedText _unknownName = JsonEncodedText.Encode(default(ReadOnlySpan)); - private class OrdinalReadOnlyMemoryByteComparer : IComparer>, IEqualityComparer> + internal class OrdinalReadOnlyMemoryByteComparer : IComparer>, IEqualityComparer> { public static OrdinalReadOnlyMemoryByteComparer Instance { get; } = new(); diff --git a/NetCord/JsonModels/JsonAttachment.cs b/NetCord/JsonModels/JsonAttachment.cs index 68a7ce744..c689dfce6 100644 --- a/NetCord/JsonModels/JsonAttachment.cs +++ b/NetCord/JsonModels/JsonAttachment.cs @@ -8,6 +8,9 @@ public class JsonAttachment : JsonEntity [JsonPropertyName("filename")] public string FileName { get; set; } + [JsonPropertyName("title")] + public string? Title { get; set; } + [JsonPropertyName("description")] public string? Description { get; set; } diff --git a/NetCord/JsonModels/JsonAvatarDecorationData.cs b/NetCord/JsonModels/JsonAvatarDecorationData.cs new file mode 100644 index 000000000..043a21419 --- /dev/null +++ b/NetCord/JsonModels/JsonAvatarDecorationData.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace NetCord.JsonModels; + +public class JsonAvatarDecorationData +{ + [JsonPropertyName("asset")] + public string Hash { get; set; } + + [JsonPropertyName("sku_id")] + public ulong SkuId { get; set; } +} diff --git a/NetCord/JsonModels/JsonComponent.cs b/NetCord/JsonModels/JsonComponent.cs index 515c2b49c..670e7643b 100644 --- a/NetCord/JsonModels/JsonComponent.cs +++ b/NetCord/JsonModels/JsonComponent.cs @@ -25,6 +25,9 @@ public class JsonComponent [JsonPropertyName("url")] public string? Url { get; set; } + [JsonPropertyName("sku_id")] + public ulong? SkuId { get; set; } + [JsonPropertyName("options")] public JsonMenuSelectOption[] Options { get; set; } diff --git a/NetCord/JsonModels/JsonGuildUser.cs b/NetCord/JsonModels/JsonGuildUser.cs index 07351745a..ed1aa9809 100644 --- a/NetCord/JsonModels/JsonGuildUser.cs +++ b/NetCord/JsonModels/JsonGuildUser.cs @@ -42,4 +42,7 @@ public class JsonGuildUser [JsonPropertyName("communication_disabled_until")] public DateTimeOffset? TimeOutUntil { get; set; } + + [JsonPropertyName("avatar_decoration_data")] + public JsonAvatarDecorationData? GuildAvatarDecorationData { get; set; } } diff --git a/NetCord/JsonModels/JsonInteraction.cs b/NetCord/JsonModels/JsonInteraction.cs index 9cd0a6329..773ea0f23 100644 --- a/NetCord/JsonModels/JsonInteraction.cs +++ b/NetCord/JsonModels/JsonInteraction.cs @@ -16,6 +16,9 @@ public class JsonInteraction : JsonEntity [JsonPropertyName("guild_id")] public ulong? GuildId { get; set; } + [JsonPropertyName("guild")] + public JsonInteractionGuildReference? GuildReference { get; set; } + [JsonPropertyName("channel")] public JsonChannel? Channel { get; set; } diff --git a/NetCord/JsonModels/JsonInteractionGuildReference.cs b/NetCord/JsonModels/JsonInteractionGuildReference.cs new file mode 100644 index 000000000..f1c1b7a32 --- /dev/null +++ b/NetCord/JsonModels/JsonInteractionGuildReference.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace NetCord.JsonModels; + +public class JsonInteractionGuildReference : JsonEntity +{ + [JsonPropertyName("features")] + public string[] Features { get; set; } + + [JsonPropertyName("locale")] + public string Locale { get; set; } +} diff --git a/NetCord/JsonModels/JsonMessage.cs b/NetCord/JsonModels/JsonMessage.cs index ca1935e84..5b4d82627 100644 --- a/NetCord/JsonModels/JsonMessage.cs +++ b/NetCord/JsonModels/JsonMessage.cs @@ -65,11 +65,14 @@ public class JsonMessage : JsonEntity [JsonPropertyName("application_id")] public ulong? ApplicationId { get; set; } + [JsonPropertyName("flags")] + public MessageFlags? Flags { get; set; } + [JsonPropertyName("message_reference")] public JsonMessageReference? MessageReference { get; set; } - [JsonPropertyName("flags")] - public MessageFlags? Flags { get; set; } + [JsonPropertyName("message_snapshots")] + public JsonMessageSnapshot[]? MessageSnapshots { get; set; } [JsonPropertyName("referenced_message")] public JsonMessage? ReferencedMessage { get; set; } @@ -107,4 +110,7 @@ public class JsonMessage : JsonEntity [JsonPropertyName("poll")] public JsonMessagePoll? Poll { get; set; } + + [JsonPropertyName("call")] + public JsonMessageCall? Call { get; set; } } diff --git a/NetCord/JsonModels/JsonMessageCall.cs b/NetCord/JsonModels/JsonMessageCall.cs new file mode 100644 index 000000000..535555719 --- /dev/null +++ b/NetCord/JsonModels/JsonMessageCall.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace NetCord.JsonModels; + +public class JsonMessageCall +{ + [JsonPropertyName("participants")] + public ulong[] Participants { get; set; } + + [JsonPropertyName("ended_timestamp")] + public DateTimeOffset? EndedAt { get; set; } +} diff --git a/NetCord/JsonModels/JsonMessageSnapshot.cs b/NetCord/JsonModels/JsonMessageSnapshot.cs new file mode 100644 index 000000000..6273b8435 --- /dev/null +++ b/NetCord/JsonModels/JsonMessageSnapshot.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace NetCord.JsonModels; + +public class JsonMessageSnapshot +{ + [JsonPropertyName("message")] + public JsonMessageSnapshotMessage Message { get; set; } +} diff --git a/NetCord/JsonModels/JsonMessageSnapshotMessage.cs b/NetCord/JsonModels/JsonMessageSnapshotMessage.cs new file mode 100644 index 000000000..737a25fd1 --- /dev/null +++ b/NetCord/JsonModels/JsonMessageSnapshotMessage.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace NetCord.JsonModels; + +public class JsonMessageSnapshotMessage +{ + [JsonPropertyName("type")] + public MessageType Type { get; set; } + + [JsonPropertyName("content")] + public string Content { get; set; } + + [JsonPropertyName("embeds")] + public JsonEmbed[] Embeds { get; set; } + + [JsonPropertyName("attachments")] + public JsonAttachment[] Attachments { get; set; } + + [JsonPropertyName("timestamp")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonPropertyName("edited_timestamp")] + public DateTimeOffset? EditedAt { get; set; } + + [JsonPropertyName("flags")] + public MessageFlags? Flags { get; set; } + + [JsonPropertyName("mentions")] + public JsonUser[] MentionedUsers { get; set; } + + [JsonPropertyName("mention_roles")] + public ulong[] MentionedRoleIds { get; set; } +} diff --git a/NetCord/JsonModels/JsonSelectMenuDefaultValueType.cs b/NetCord/JsonModels/JsonSelectMenuDefaultValueType.cs index 371e1bd44..992b08351 100644 --- a/NetCord/JsonModels/JsonSelectMenuDefaultValueType.cs +++ b/NetCord/JsonModels/JsonSelectMenuDefaultValueType.cs @@ -4,7 +4,7 @@ namespace NetCord.JsonModels; -[JsonConverter(typeof(StringEnumConverterWithErrorHandling))] +[JsonConverter(typeof(SafeStringEnumConverter))] public enum JsonSelectMenuDefaultValueType { [JsonPropertyName("user")] diff --git a/NetCord/JsonModels/JsonUser.cs b/NetCord/JsonModels/JsonUser.cs index 2c96a5296..f2ac49400 100644 --- a/NetCord/JsonModels/JsonUser.cs +++ b/NetCord/JsonModels/JsonUser.cs @@ -50,8 +50,8 @@ public class JsonUser : JsonEntity [JsonPropertyName("public_flags")] public UserFlags? PublicFlags { get; set; } - [JsonPropertyName("avatar_decoration")] - public string? AvatarDecorationHash { get; set; } + [JsonPropertyName("avatar_decoration_data")] + public JsonAvatarDecorationData? AvatarDecorationData { get; set; } [JsonPropertyName("member")] public JsonGuildUser? GuildUser { get; set; } diff --git a/NetCord/MessagePollProperties.cs b/NetCord/MessagePollProperties.cs index b7f19af9c..f46226ffe 100644 --- a/NetCord/MessagePollProperties.cs +++ b/NetCord/MessagePollProperties.cs @@ -2,21 +2,43 @@ namespace NetCord; -public partial class MessagePollProperties(MessagePollMediaProperties question, IEnumerable answers, int durationInHours) +/// +/// +/// +/// The question of the poll. +/// Each of the answers available in the poll, up to 10. +public partial class MessagePollProperties(MessagePollMediaProperties question, IEnumerable answers) { + /// + /// The question of the poll. + /// [JsonPropertyName("question")] public MessagePollMediaProperties Question { get; set; } = question; + /// + /// Each of the answers available in the poll, up to 10. + /// [JsonPropertyName("answers")] public IEnumerable Answers { get; set; } = answers; + /// + /// Number of hours the poll should be open for, up to 32 days. Defaults to 24. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonPropertyName("duration")] - public int DurationInHours { get; set; } = durationInHours; + public int? DurationInHours { get; set; } + /// + /// Whether a user can select multiple answers. Defaults to . + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonPropertyName("allow_multiselect")] public bool AllowMultiselect { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + /// + /// The layout of the poll. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonPropertyName("layout_type")] public MessagePollLayoutType? LayoutType { get; set; } } diff --git a/NetCord/MessageReferenceType.cs b/NetCord/MessageReferenceType.cs new file mode 100644 index 000000000..327bee75e --- /dev/null +++ b/NetCord/MessageReferenceType.cs @@ -0,0 +1,7 @@ +namespace NetCord; + +public enum MessageReferenceType : byte +{ + Reply = 0, + Forward = 1, +} diff --git a/NetCord/MessageSnapshot.cs b/NetCord/MessageSnapshot.cs new file mode 100644 index 000000000..83f696de7 --- /dev/null +++ b/NetCord/MessageSnapshot.cs @@ -0,0 +1,11 @@ +using NetCord.JsonModels; +using NetCord.Rest; + +namespace NetCord; + +public class MessageSnapshot(JsonMessageSnapshot jsonModel, ulong? guildId, RestClient client) : IJsonModel +{ + JsonMessageSnapshot IJsonModel.JsonModel => jsonModel; + + public MessageSnapshotMessage Message { get; } = new(jsonModel.Message, guildId, client); +} diff --git a/NetCord/MessageSnapshotMessage.cs b/NetCord/MessageSnapshotMessage.cs new file mode 100644 index 000000000..dc0207b82 --- /dev/null +++ b/NetCord/MessageSnapshotMessage.cs @@ -0,0 +1,57 @@ +using NetCord.JsonModels; +using NetCord.Rest; + +namespace NetCord; + +public class MessageSnapshotMessage(JsonMessageSnapshotMessage jsonModel, ulong? guildId, RestClient client) : IJsonModel +{ + JsonMessageSnapshotMessage IJsonModel.JsonModel => jsonModel; + + /// + /// The type of the message. + /// + public MessageType Type => jsonModel.Type; + + /// + /// The text contents of the message. + /// + public string Content => jsonModel.Content; + + /// + /// A list of objects containing any embedded content present in the message. + /// + public IReadOnlyList Embeds { get; } = jsonModel.Embeds.Select(e => new Embed(e)).ToArray(); + + /// + /// A dictionary of objects indexed by their IDs, containing any files attached in the message. + /// + public IReadOnlyDictionary Attachments { get; } = jsonModel.Attachments.ToDictionary(a => a.Id, Attachment.CreateFromJson); + + /// + /// When the message was edited (or null if never). + /// + public DateTimeOffset? EditedAt => jsonModel.EditedAt; + + /// + /// A object indicating the message's applied flags. + /// + public MessageFlags Flags => jsonModel.Flags.GetValueOrDefault(); + + /// + /// A dictionary of objects indexed by their IDs, containing users specifically mentioned in the message. + /// + public IReadOnlyDictionary MentionedUsers { get; } = jsonModel.MentionedUsers.ToDictionary(u => u.Id, u => + { + var guildUser = u.GuildUser; + if (guildUser is null) + return new User(u, client); + + guildUser.User = u; + return new GuildUser(guildUser, guildId.GetValueOrDefault(), client); + }); + + /// + /// A list of IDs corresponding to roles specifically mentioned in the message. + /// + public IReadOnlyList MentionedRoleIds => jsonModel.MentionedRoleIds; +} diff --git a/NetCord/NetCord.csproj b/NetCord/NetCord.csproj index 473837e7e..21b525f69 100644 --- a/NetCord/NetCord.csproj +++ b/NetCord/NetCord.csproj @@ -14,7 +14,7 @@ SmallSquare.png MIT $(VersionPrefix) - alpha.302 + alpha.311 The modern and fully customizable C# Discord library. true diff --git a/NetCord/PartialGuildUser.cs b/NetCord/PartialGuildUser.cs index 4f1dac59f..f9ba0aad3 100644 --- a/NetCord/PartialGuildUser.cs +++ b/NetCord/PartialGuildUser.cs @@ -6,10 +6,19 @@ namespace NetCord; /// /// Represents a object that lacks a field, as well as methods relying on it. /// -public class PartialGuildUser(JsonGuildUser jsonModel, RestClient client) : User(jsonModel.User, client), IJsonModel +public class PartialGuildUser : User, IJsonModel { JsonGuildUser IJsonModel.JsonModel => _jsonModel; - private protected new readonly JsonGuildUser _jsonModel = jsonModel; + private protected new readonly JsonGuildUser _jsonModel; + + public PartialGuildUser(JsonGuildUser jsonModel, RestClient client) : base(jsonModel.User, client) + { + _jsonModel = jsonModel; + + var guildAvatarDecorationData = jsonModel.GuildAvatarDecorationData; + if (guildAvatarDecorationData is not null) + GuildAvatarDecorationData = new(guildAvatarDecorationData); + } /// /// The user's guild nickname. @@ -66,8 +75,18 @@ public class PartialGuildUser(JsonGuildUser jsonModel, RestClient client) : User /// public DateTimeOffset? TimeOutUntil => _jsonModel.TimeOutUntil; + /// + /// Data for the guild user's avatar decoration. + /// + public AvatarDecorationData? GuildAvatarDecorationData { get; } + /// /// Whether the user has a guild avatar set. /// public bool HasGuildAvatar => GuildAvatarHash is not null; + + /// + /// Whether the user has a set avatar decoration. + /// + public bool HasGuildAvatarDecoration => GuildAvatarDecorationData is not null; } diff --git a/NetCord/Permissions.cs b/NetCord/Permissions.cs index c8b23d23c..869a4e9c1 100644 --- a/NetCord/Permissions.cs +++ b/NetCord/Permissions.cs @@ -242,4 +242,9 @@ public enum Permissions : ulong /// Allows sending polls. ///
SendPolls = 1uL << 49, + + /// + /// Allows user-installed apps to send public responses. When disabled, users will still be allowed to use their apps but the responses will be ephemeral. This only applies to apps not also installed to the server. + /// + UseExternalApplications = 1uL << 50, } diff --git a/NetCord/ReactionType.cs b/NetCord/ReactionType.cs new file mode 100644 index 000000000..eab5af15c --- /dev/null +++ b/NetCord/ReactionType.cs @@ -0,0 +1,7 @@ +namespace NetCord; + +public enum ReactionType : byte +{ + Normal = 0, + Burst = 1, +} diff --git a/NetCord/Rest/ApplicationEmojiOptions.cs b/NetCord/Rest/ApplicationEmojiOptions.cs new file mode 100644 index 000000000..6f5653dab --- /dev/null +++ b/NetCord/Rest/ApplicationEmojiOptions.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace NetCord.Rest; + +public partial class ApplicationEmojiOptions +{ + internal ApplicationEmojiOptions() + { + } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("name")] + public string? Name { get; set; } +} diff --git a/NetCord/Rest/ApplicationEmojiProperties.cs b/NetCord/Rest/ApplicationEmojiProperties.cs new file mode 100644 index 000000000..201cbd5f0 --- /dev/null +++ b/NetCord/Rest/ApplicationEmojiProperties.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace NetCord.Rest; + +public partial class ApplicationEmojiProperties(string name, ImageProperties image) +{ + [JsonPropertyName("name")] + public string Name { get; set; } = name; + + [JsonPropertyName("image")] + public ImageProperties Image { get; set; } = image; +} diff --git a/NetCord/Rest/AttachmentProperties.cs b/NetCord/Rest/AttachmentProperties.cs index 524acabed..f7cd394c8 100644 --- a/NetCord/Rest/AttachmentProperties.cs +++ b/NetCord/Rest/AttachmentProperties.cs @@ -26,6 +26,11 @@ protected AttachmentProperties(string fileName) ///
public string FileName { get; set; } + /// + /// Title of the attachment. + /// + public string? Title { get; set; } + /// /// Description for the file (max 1024 characters for attachments sent by message, max 200 characters for attachments used for sticker creation). /// diff --git a/NetCord/Rest/ComponentProperties/ButtonProperties.cs b/NetCord/Rest/ComponentProperties/ButtonProperties.cs index 83991c3c1..07e228709 100644 --- a/NetCord/Rest/ComponentProperties/ButtonProperties.cs +++ b/NetCord/Rest/ComponentProperties/ButtonProperties.cs @@ -2,7 +2,7 @@ namespace NetCord.Rest; -public partial class ButtonProperties : IButtonProperties +public partial class ButtonProperties : ICustomizableButtonProperties { /// /// Developer-defined identifier for the button (max 100 characters). diff --git a/NetCord/Rest/ComponentProperties/IButtonProperties.cs b/NetCord/Rest/ComponentProperties/IButtonProperties.cs index b26c71580..be89a564c 100644 --- a/NetCord/Rest/ComponentProperties/IButtonProperties.cs +++ b/NetCord/Rest/ComponentProperties/IButtonProperties.cs @@ -9,23 +9,13 @@ public partial interface IButtonProperties /// /// Style of the button. /// - public ButtonStyle Style { get; set; } + public ButtonStyle Style { get; } /// /// Type of the component. /// public ComponentType ComponentType { get; } - /// - /// Text that appears on the button (max 80 characters). - /// - public string? Label { get; set; } - - /// - /// Emoji that appears on the button. - /// - public EmojiProperties? Emoji { get; set; } - /// /// Whether the button is disabled. /// @@ -39,11 +29,14 @@ public override void Write(Utf8JsonWriter writer, IButtonProperties button, Json { switch (button) { - case ButtonProperties actionButton: - JsonSerializer.Serialize(writer, actionButton, Serialization.Default.ButtonProperties); + case ButtonProperties buttonProperties: + JsonSerializer.Serialize(writer, buttonProperties, Serialization.Default.ButtonProperties); + break; + case LinkButtonProperties linkButtonProperties: + JsonSerializer.Serialize(writer, linkButtonProperties, Serialization.Default.LinkButtonProperties); break; - case LinkButtonProperties linkButton: - JsonSerializer.Serialize(writer, linkButton, Serialization.Default.LinkButtonProperties); + case PremiumButtonProperties premiumButtonProperties: + JsonSerializer.Serialize(writer, premiumButtonProperties, Serialization.Default.PremiumButtonProperties); break; default: throw new InvalidOperationException($"Invalid {nameof(IButtonProperties)} value."); diff --git a/NetCord/Rest/ComponentProperties/ICustomizableButtonProperties.cs b/NetCord/Rest/ComponentProperties/ICustomizableButtonProperties.cs new file mode 100644 index 000000000..96d7a3460 --- /dev/null +++ b/NetCord/Rest/ComponentProperties/ICustomizableButtonProperties.cs @@ -0,0 +1,14 @@ +namespace NetCord.Rest; + +public partial interface ICustomizableButtonProperties : IButtonProperties +{ + /// + /// Text that appears on the button (max 80 characters). + /// + public string? Label { get; set; } + + /// + /// Emoji that appears on the button. + /// + public EmojiProperties? Emoji { get; set; } +} diff --git a/NetCord/Rest/ComponentProperties/LinkButtonProperties.cs b/NetCord/Rest/ComponentProperties/LinkButtonProperties.cs index 76d2ec2bb..03bdc6c62 100644 --- a/NetCord/Rest/ComponentProperties/LinkButtonProperties.cs +++ b/NetCord/Rest/ComponentProperties/LinkButtonProperties.cs @@ -2,7 +2,7 @@ namespace NetCord.Rest; -public partial class LinkButtonProperties : IButtonProperties +public partial class LinkButtonProperties : ICustomizableButtonProperties { /// /// Url of the button. @@ -11,7 +11,7 @@ public partial class LinkButtonProperties : IButtonProperties public string Url { get; set; } [JsonPropertyName("style")] - public ButtonStyle Style { get; set; } + public ButtonStyle Style => (ButtonStyle)5; [JsonPropertyName("type")] public ComponentType ComponentType => ComponentType.Button; @@ -37,7 +37,6 @@ public LinkButtonProperties(string url, string label) { Url = url; Label = label; - Style = (ButtonStyle)5; } /// @@ -49,7 +48,6 @@ public LinkButtonProperties(string url, EmojiProperties emoji) { Url = url; Emoji = emoji; - Style = (ButtonStyle)5; } /// @@ -63,6 +61,5 @@ public LinkButtonProperties(string url, string label, EmojiProperties emoji) Url = url; Label = label; Emoji = emoji; - Style = (ButtonStyle)5; } } diff --git a/NetCord/Rest/ComponentProperties/PremiumButtonProperties.cs b/NetCord/Rest/ComponentProperties/PremiumButtonProperties.cs new file mode 100644 index 000000000..6e5dfd9c0 --- /dev/null +++ b/NetCord/Rest/ComponentProperties/PremiumButtonProperties.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace NetCord.Rest; + +public partial class PremiumButtonProperties(ulong skuId) : IButtonProperties +{ + [JsonPropertyName("sku_id")] + public ulong SkuId { get; set; } = skuId; + + [JsonPropertyName("style")] + public ButtonStyle Style => (ButtonStyle)6; + + [JsonPropertyName("type")] + public ComponentType ComponentType => ComponentType.Button; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("disabled")] + public bool Disabled { get; set; } +} diff --git a/NetCord/Rest/ConnectionType.cs b/NetCord/Rest/ConnectionType.cs index 5d3f27de7..5fb5b678a 100644 --- a/NetCord/Rest/ConnectionType.cs +++ b/NetCord/Rest/ConnectionType.cs @@ -2,12 +2,18 @@ namespace NetCord.Rest; -[JsonConverter(typeof(JsonConverters.StringEnumConverterWithErrorHandling))] +[JsonConverter(typeof(JsonConverters.SafeStringEnumConverter))] public enum ConnectionType { [JsonPropertyName("battlenet")] BattleNet, + [JsonPropertyName("bungie")] + Bungie, + + [JsonPropertyName("domain")] + Domain, + [JsonPropertyName("ebay")] Ebay, @@ -38,6 +44,9 @@ public enum ConnectionType [JsonPropertyName("riotgames")] RiotGames, + [JsonPropertyName("roblox")] + Roblox, + [JsonPropertyName("spotify")] Spotify, diff --git a/NetCord/Rest/EmbedImageProperties.cs b/NetCord/Rest/EmbedImageProperties.cs index e962284ea..1b388f9d9 100644 --- a/NetCord/Rest/EmbedImageProperties.cs +++ b/NetCord/Rest/EmbedImageProperties.cs @@ -16,13 +16,4 @@ public partial class EmbedImageProperties(string? url) public string? Url { get; set; } = url; public static implicit operator EmbedImageProperties(string? url) => new(url); - - public static implicit operator EmbedImageProperties(AttachmentProperties attachment) => FromAttachment(attachment.FileName); - - /// - /// Creates new based on . - /// - /// Attachment file name. - /// - public static EmbedImageProperties FromAttachment(string attachmentFileName) => new($"attachment://{attachmentFileName}"); } diff --git a/NetCord/Rest/ForumGuildThreadMessageProperties.cs b/NetCord/Rest/ForumGuildThreadMessageProperties.cs index b863b6da2..f5451880f 100644 --- a/NetCord/Rest/ForumGuildThreadMessageProperties.cs +++ b/NetCord/Rest/ForumGuildThreadMessageProperties.cs @@ -2,7 +2,7 @@ namespace NetCord.Rest; -public partial class ForumGuildThreadMessageProperties +public partial class ForumGuildThreadMessageProperties : IMessageProperties { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonPropertyName("content")] diff --git a/NetCord/Rest/IMessageProperties.cs b/NetCord/Rest/IMessageProperties.cs new file mode 100644 index 000000000..c4d3947bd --- /dev/null +++ b/NetCord/Rest/IMessageProperties.cs @@ -0,0 +1,16 @@ +namespace NetCord.Rest; + +public partial interface IMessageProperties +{ + public string? Content { get; set; } + + public IEnumerable? Embeds { get; set; } + + public AllowedMentionsProperties? AllowedMentions { get; set; } + + public IEnumerable? Attachments { get; set; } + + public IEnumerable? Components { get; set; } + + public MessageFlags? Flags { get; set; } +} diff --git a/NetCord/Rest/InteractionCallback.cs b/NetCord/Rest/InteractionCallback.cs index 30a354b0f..aa01f2526 100644 --- a/NetCord/Rest/InteractionCallback.cs +++ b/NetCord/Rest/InteractionCallback.cs @@ -66,12 +66,6 @@ public static InteractionCallback Auto public static InteractionCallback Modal(ModalProperties modal) => new(InteractionCallbackType.Modal, modal); - /// - /// Respond to an interaction with an upgrade button, only available for apps with monetization enabled. - /// - public static InteractionCallback PremiumRequired - => new(InteractionCallbackType.PremiumRequired); - public HttpContent Serialize() { switch (this) diff --git a/NetCord/Rest/InteractionCallbackType.cs b/NetCord/Rest/InteractionCallbackType.cs index db4b68e5e..c2a70d2c3 100644 --- a/NetCord/Rest/InteractionCallbackType.cs +++ b/NetCord/Rest/InteractionCallbackType.cs @@ -36,9 +36,4 @@ public enum InteractionCallbackType /// Respond to an interaction with a popup modal. /// Modal = 9, - - /// - /// Respond to an interaction with an upgrade button, only available for apps with monetization enabled. - /// - PremiumRequired = 10, } diff --git a/NetCord/Rest/InteractionMessageProperties.cs b/NetCord/Rest/InteractionMessageProperties.cs index 515c97536..6fa8bb554 100644 --- a/NetCord/Rest/InteractionMessageProperties.cs +++ b/NetCord/Rest/InteractionMessageProperties.cs @@ -2,7 +2,7 @@ namespace NetCord.Rest; -public partial class InteractionMessageProperties : IHttpSerializable +public partial class InteractionMessageProperties : IHttpSerializable, IMessageProperties { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonPropertyName("tts")] diff --git a/NetCord/Rest/GuildInviteProperties.cs b/NetCord/Rest/InviteProperties.cs similarity index 85% rename from NetCord/Rest/GuildInviteProperties.cs rename to NetCord/Rest/InviteProperties.cs index 134bc42ea..e9bd3e734 100644 --- a/NetCord/Rest/GuildInviteProperties.cs +++ b/NetCord/Rest/InviteProperties.cs @@ -2,7 +2,7 @@ namespace NetCord.Rest; -public partial class GuildInviteProperties +public partial class InviteProperties { [JsonPropertyName("max_age")] public int? MaxAge { get; set; } @@ -17,7 +17,7 @@ public partial class GuildInviteProperties public bool? Unique { get; set; } [JsonPropertyName("target_type")] - public GuildInviteTargetType? TargetType { get; set; } + public InviteTargetType? TargetType { get; set; } [JsonPropertyName("target_user_id")] public ulong? TargetUserId { get; set; } diff --git a/NetCord/Rest/JsonModels/JsonRestGuildInvite.cs b/NetCord/Rest/JsonModels/JsonRestInvite.cs similarity index 90% rename from NetCord/Rest/JsonModels/JsonRestGuildInvite.cs rename to NetCord/Rest/JsonModels/JsonRestInvite.cs index 890ccfab7..b64cee495 100644 --- a/NetCord/Rest/JsonModels/JsonRestGuildInvite.cs +++ b/NetCord/Rest/JsonModels/JsonRestInvite.cs @@ -4,8 +4,11 @@ namespace NetCord.Rest.JsonModels; -public class JsonRestGuildInvite +public class JsonRestInvite { + [JsonPropertyName("type")] + public InviteType Type { get; set; } + [JsonPropertyName("code")] public string Code { get; set; } @@ -19,7 +22,7 @@ public class JsonRestGuildInvite public JsonUser? Inviter { get; set; } [JsonPropertyName("target_type")] - public GuildInviteTargetType? TargetType { get; set; } + public InviteTargetType? TargetType { get; set; } [JsonPropertyName("target_user")] public JsonUser? TargetUser { get; set; } diff --git a/NetCord/Rest/MentionableValueProperties.cs b/NetCord/Rest/MentionableValueProperties.cs index 58d43cb8d..b5a71e09c 100644 --- a/NetCord/Rest/MentionableValueProperties.cs +++ b/NetCord/Rest/MentionableValueProperties.cs @@ -9,7 +9,7 @@ public partial struct MentionableValueProperties(ulong id, MentionableValueType [JsonPropertyName("id")] public ulong Id { get; set; } = id; - [JsonConverter(typeof(StringEnumConverterWithErrorHandling))] + [JsonConverter(typeof(SafeStringEnumConverter))] [JsonPropertyName("type")] public MentionableValueType Type { get; set; } = type; } diff --git a/NetCord/Rest/MessageCall.cs b/NetCord/Rest/MessageCall.cs new file mode 100644 index 000000000..2799ded72 --- /dev/null +++ b/NetCord/Rest/MessageCall.cs @@ -0,0 +1,12 @@ +using NetCord.JsonModels; + +namespace NetCord.Rest; + +public class MessageCall(JsonMessageCall jsonModel) : IJsonModel +{ + JsonMessageCall IJsonModel.JsonModel => jsonModel; + + public IReadOnlyList Participants => jsonModel.Participants; + + public DateTimeOffset? EndedAt => jsonModel.EndedAt; +} diff --git a/NetCord/Rest/MessageProperties.cs b/NetCord/Rest/MessageProperties.cs index 15d32a354..b7773825a 100644 --- a/NetCord/Rest/MessageProperties.cs +++ b/NetCord/Rest/MessageProperties.cs @@ -2,7 +2,7 @@ namespace NetCord.Rest; -public partial class MessageProperties : IHttpSerializable +public partial class MessageProperties : IHttpSerializable, IMessageProperties { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonPropertyName("content")] diff --git a/NetCord/Rest/MessageReactionsPaginationProperties.cs b/NetCord/Rest/MessageReactionsPaginationProperties.cs new file mode 100644 index 000000000..99175e95e --- /dev/null +++ b/NetCord/Rest/MessageReactionsPaginationProperties.cs @@ -0,0 +1,9 @@ +namespace NetCord.Rest; + +public partial record MessageReactionsPaginationProperties : PaginationProperties, IPaginationProperties +{ + public ReactionType? Type { get; set; } + + static MessageReactionsPaginationProperties IPaginationProperties.Create() => new(); + static MessageReactionsPaginationProperties IPaginationProperties.Create(MessageReactionsPaginationProperties properties) => new(properties); +} diff --git a/NetCord/Rest/MessageReferenceProperties.cs b/NetCord/Rest/MessageReferenceProperties.cs index e7e5ecf35..291270cda 100644 --- a/NetCord/Rest/MessageReferenceProperties.cs +++ b/NetCord/Rest/MessageReferenceProperties.cs @@ -2,11 +2,43 @@ namespace NetCord.Rest; -public partial class MessageReferenceProperties(ulong messageId, bool failIfNotExists = true) +public partial class MessageReferenceProperties { + public static MessageReferenceProperties Reply(ulong messageId, bool failIfNotExists = true) + { + return new() + { + Type = MessageReferenceType.Reply, + MessageId = messageId, + FailIfNotExists = failIfNotExists, + }; + } + + public static MessageReferenceProperties Forward(ulong channelId, ulong messageId, bool failIfNotExists = true) + { + return new() + { + Type = MessageReferenceType.Forward, + ChannelId = channelId, + MessageId = messageId, + FailIfNotExists = failIfNotExists, + }; + } + + private MessageReferenceProperties() + { + } + + [JsonPropertyName("type")] + public MessageReferenceType Type { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("channel_id")] + public ulong? ChannelId { get; set; } + [JsonPropertyName("message_id")] - public ulong Id { get; set; } = messageId; + public ulong MessageId { get; set; } [JsonPropertyName("fail_if_not_exists")] - public bool FailIfNotExists { get; set; } = failIfNotExists; + public bool FailIfNotExists { get; set; } } diff --git a/NetCord/Rest/ReplyMessageProperties.cs b/NetCord/Rest/ReplyMessageProperties.cs index 0414bf2b6..709511178 100644 --- a/NetCord/Rest/ReplyMessageProperties.cs +++ b/NetCord/Rest/ReplyMessageProperties.cs @@ -1,6 +1,6 @@ namespace NetCord.Rest; -public partial class ReplyMessageProperties +public partial class ReplyMessageProperties : IMessageProperties { public string? Content { get; set; } public NonceProperties? Nonce { get; set; } @@ -12,6 +12,7 @@ public partial class ReplyMessageProperties public IEnumerable? Components { get; set; } public IEnumerable? StickerIds { get; set; } public MessageFlags? Flags { get; set; } + public MessagePollProperties? Poll { get; set; } public MessageProperties ToMessageProperties(ulong messageReferenceId) { @@ -23,10 +24,11 @@ public MessageProperties ToMessageProperties(ulong messageReferenceId) Attachments = Attachments, Embeds = Embeds, AllowedMentions = AllowedMentions ?? new(), - MessageReference = new(messageReferenceId, FailIfNotExists.GetValueOrDefault(true)), + MessageReference = MessageReferenceProperties.Reply(messageReferenceId, FailIfNotExists.GetValueOrDefault(true)), Components = Components, StickerIds = StickerIds, Flags = Flags, + Poll = Poll, }; } diff --git a/NetCord/Rest/RestAuditLogEntry.cs b/NetCord/Rest/RestAuditLogEntry.cs index d2d48b48f..d72929ea4 100644 --- a/NetCord/Rest/RestAuditLogEntry.cs +++ b/NetCord/Rest/RestAuditLogEntry.cs @@ -3,7 +3,7 @@ namespace NetCord.Rest; -public class RestAuditLogEntry(JsonAuditLogEntry jsonModel, RestAuditLogEntryData data) : AuditLogEntry(jsonModel) +public class RestAuditLogEntry(JsonAuditLogEntry jsonModel, RestAuditLogEntryData data, ulong guildId) : AuditLogEntry(jsonModel, guildId) { /// /// Data of objects referenced in the audit log. diff --git a/NetCord/Rest/RestClient.AuditLog.cs b/NetCord/Rest/RestClient.AuditLog.cs index 9a156cb3b..c4aee1e13 100644 --- a/NetCord/Rest/RestClient.AuditLog.cs +++ b/NetCord/Rest/RestClient.AuditLog.cs @@ -19,17 +19,17 @@ public IAsyncEnumerable GetGuildAuditLogAsync(ulong guildId, { var jsonAuditLog = await s.ToObjectAsync(Serialization.Default.JsonAuditLog).ConfigureAwait(false); RestAuditLogEntryData data = new(jsonAuditLog, this); - return jsonAuditLog.AuditLogEntries.Select(e => new RestAuditLogEntry(e, data)); + return jsonAuditLog.AuditLogEntries.Select(e => new RestAuditLogEntry(e, data, guildId)); }, e => e.Id, HttpMethod.Get, $"/guilds/{guildId}/audit-logs", new(paginationProperties.Limit.GetValueOrDefault(), paginationProperties.Direction.GetValueOrDefault(), id => id.ToString(), userId.HasValue ? (actionType.HasValue - ? $"?user_id={userId.GetValueOrDefault()}&action_type={actionType.GetValueOrDefault()}&" + ? $"?user_id={userId.GetValueOrDefault()}&action_type={(int)actionType.GetValueOrDefault()}&" : $"?user_id={userId.GetValueOrDefault()}&") : (actionType.HasValue - ? $"?action_type={actionType.GetValueOrDefault()}&" + ? $"?action_type={(int)actionType.GetValueOrDefault()}&" : "?")), new(guildId), properties); diff --git a/NetCord/Rest/RestClient.Channel.cs b/NetCord/Rest/RestClient.Channel.cs index 292a6d619..d56cee624 100644 --- a/NetCord/Rest/RestClient.Channel.cs +++ b/NetCord/Rest/RestClient.Channel.cs @@ -1,6 +1,4 @@ -using NetCord.Gateway; - -namespace NetCord.Rest; +namespace NetCord.Rest; public partial class RestClient { @@ -57,12 +55,12 @@ public async Task> GetMessagesAroundAsyn => (await (await SendRequestAsync(HttpMethod.Get, $"/channels/{channelId}/messages", $"?limit={limit.GetValueOrDefault(100)}&around={messageId}", new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonMessageArray).ConfigureAwait(false)).ToDictionary(m => m.Id, m => new RestMessage(m, this)); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public async Task GetMessageAsync(ulong channelId, ulong messageId, RestRequestProperties? properties = null) => new(await (await SendRequestAsync(HttpMethod.Get, $"/channels/{channelId}/messages/{messageId}", null, new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonMessage).ConfigureAwait(false), this); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), TypeNameOverride = "Message")] public async Task SendMessageAsync(ulong channelId, MessageProperties message, RestRequestProperties? properties = null) { using (HttpContent content = message.Serialize()) @@ -71,31 +69,33 @@ public async Task SendMessageAsync(ulong channelId, MessageProperti [GenerateAlias([typeof(AnnouncementGuildChannel)], nameof(AnnouncementGuildChannel.Id))] [GenerateAlias([typeof(AnnouncementGuildThread)], nameof(AnnouncementGuildThread.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public async Task CrosspostMessageAsync(ulong channelId, ulong messageId, RestRequestProperties? properties = null) => new(await (await SendRequestAsync(HttpMethod.Post, $"/channels/{channelId}/messages/{messageId}/crosspost", null, new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonMessage).ConfigureAwait(false), this); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public Task AddMessageReactionAsync(ulong channelId, ulong messageId, ReactionEmojiProperties emoji, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Put, $"/channels/{channelId}/messages/{messageId}/reactions/{ReactionEmojiToString(emoji)}/@me", null, new(channelId), properties); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public Task DeleteMessageReactionAsync(ulong channelId, ulong messageId, ReactionEmojiProperties emoji, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Delete, $"/channels/{channelId}/messages/{messageId}/reactions/{ReactionEmojiToString(emoji)}/@me", null, new(channelId), properties); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public Task DeleteMessageReactionAsync(ulong channelId, ulong messageId, ReactionEmojiProperties emoji, ulong userId, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Delete, $"/channels/{channelId}/messages/{messageId}/reactions/{ReactionEmojiToString(emoji)}/{userId}", null, new(channelId), properties); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] - public IAsyncEnumerable GetMessageReactionsAsync(ulong channelId, ulong messageId, ReactionEmojiProperties emoji, PaginationProperties? paginationProperties = null, RestRequestProperties? properties = null) + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + public IAsyncEnumerable GetMessageReactionsAsync(ulong channelId, ulong messageId, ReactionEmojiProperties emoji, MessageReactionsPaginationProperties? paginationProperties = null, RestRequestProperties? properties = null) { paginationProperties = PaginationProperties.PrepareWithDirectionValidation(paginationProperties, PaginationDirection.After, 100); + var type = paginationProperties.Type; + return new QueryPaginationAsyncEnumerable( this, paginationProperties, @@ -103,25 +103,25 @@ public IAsyncEnumerable GetMessageReactionsAsync(ulong channelId, ulong me u => u.Id, HttpMethod.Get, $"/channels/{channelId}/messages/{messageId}/reactions/{ReactionEmojiToString(emoji)}", - new(paginationProperties.Limit.GetValueOrDefault(), paginationProperties.Direction.GetValueOrDefault(), id => id.ToString()), + new(paginationProperties.Limit.GetValueOrDefault(), paginationProperties.Direction.GetValueOrDefault(), id => id.ToString(), type.HasValue ? $"?type={(byte)type.GetValueOrDefault()}&" : "?"), new(channelId), properties); } [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public Task DeleteAllMessageReactionsAsync(ulong channelId, ulong messageId, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Delete, $"/channels/{channelId}/messages/{messageId}/reactions", null, new(channelId), properties); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public Task DeleteAllMessageReactionsAsync(ulong channelId, ulong messageId, ReactionEmojiProperties emoji, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Delete, $"/channels/{channelId}/messages/{messageId}/reactions/{ReactionEmojiToString(emoji)}", null, new(channelId), properties); private static string ReactionEmojiToString(ReactionEmojiProperties emoji) => emoji.Id.HasValue ? $"{emoji.Name}:{emoji.Id.GetValueOrDefault()}" : emoji.Name; [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public async Task ModifyMessageAsync(ulong channelId, ulong messageId, Action action, RestRequestProperties? properties = null) { MessageOptions messageOptions = new(); @@ -131,7 +131,7 @@ public async Task ModifyMessageAsync(ulong channelId, ulong message } [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public Task DeleteMessageAsync(ulong channelId, ulong messageId, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Delete, $"/channels/{channelId}/messages/{messageId}", null, new(channelId), properties); @@ -197,15 +197,15 @@ public async Task ModifyGuildChannelPermissionsAsync(ulong channelId, Permission } [GenerateAlias([typeof(IGuildChannel)], nameof(IGuildChannel.Id))] - public async Task> GetGuildChannelInvitesAsync(ulong channelId, RestRequestProperties? properties = null) - => (await (await SendRequestAsync(HttpMethod.Get, $"/channels/{channelId}/invites", null, new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestGuildInviteArray).ConfigureAwait(false)).Select(r => new RestGuildInvite(r, this)); + public async Task> GetGuildChannelInvitesAsync(ulong channelId, RestRequestProperties? properties = null) + => (await (await SendRequestAsync(HttpMethod.Get, $"/channels/{channelId}/invites", null, new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestInviteArray).ConfigureAwait(false)).Select(r => new RestInvite(r, this)); [GenerateAlias([typeof(IGuildChannel)], nameof(IGuildChannel.Id))] - public async Task CreateGuildChannelInviteAsync(ulong channelId, GuildInviteProperties? guildInviteProperties = null, RestRequestProperties? properties = null) + public async Task CreateGuildChannelInviteAsync(ulong channelId, InviteProperties? inviteProperties = null, RestRequestProperties? properties = null) #pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. { - using (HttpContent content = new JsonContent(guildInviteProperties, Serialization.Default.GuildInviteProperties)) - return new(await (await SendRequestAsync(HttpMethod.Post, content, $"/channels/{channelId}/invites", null, new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestGuildInvite).ConfigureAwait(false), this); + using (HttpContent content = new JsonContent(inviteProperties, Serialization.Default.InviteProperties)) + return new(await (await SendRequestAsync(HttpMethod.Post, content, $"/channels/{channelId}/invites", null, new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestInvite).ConfigureAwait(false), this); } #pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. @@ -238,12 +238,12 @@ public async Task> GetPinnedMessagesAsyn => (await (await SendRequestAsync(HttpMethod.Get, $"/channels/{channelId}/pins", null, new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonMessageArray).ConfigureAwait(false)).ToDictionary(m => m.Id, m => new RestMessage(m, this)); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public Task PinMessageAsync(ulong channelId, ulong messageId, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Put, $"/channels/{channelId}/pins/{messageId}", null, new(channelId), properties); [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public Task UnpinMessageAsync(ulong channelId, ulong messageId, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Delete, $"/channels/{channelId}/pins/{messageId}", null, new(channelId), properties); @@ -259,7 +259,7 @@ public Task GroupDMChannelDeleteUserAsync(ulong channelId, ulong userId, RestReq => SendRequestAsync(HttpMethod.Delete, $"/channels/{channelId}/recipients/{userId}", null, new(channelId), properties); [GenerateAlias([typeof(TextGuildChannel)], nameof(TextGuildChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = "Message")] public async Task CreateGuildThreadAsync(ulong channelId, ulong messageId, GuildThreadFromMessageProperties threadFromMessageProperties, RestRequestProperties? properties = null) { using (HttpContent content = new JsonContent(threadFromMessageProperties, Serialization.Default.GuildThreadFromMessageProperties)) diff --git a/NetCord/Rest/RestClient.Emoji.cs b/NetCord/Rest/RestClient.Emoji.cs index 6492cc67f..361037aa7 100644 --- a/NetCord/Rest/RestClient.Emoji.cs +++ b/NetCord/Rest/RestClient.Emoji.cs @@ -34,4 +34,35 @@ public async Task ModifyGuildEmojiAsync(ulong guildId, ulong emojiId [GenerateAlias([typeof(GuildEmoji)], nameof(GuildEmoji.GuildId), nameof(GuildEmoji.Id))] public Task DeleteGuildEmojiAsync(ulong guildId, ulong emojiId, RestRequestProperties? properties = null) => SendRequestAsync(HttpMethod.Delete, $"/guilds/{guildId}/emojis/{emojiId}", null, new(guildId), properties); + + [GenerateAlias([typeof(Application)], nameof(Application.Id))] + public async Task> GetApplicationEmojisAsync(ulong applicationId, RestRequestProperties? properties = null) + => (await (await SendRequestAsync(HttpMethod.Get, $"/applications/{applicationId}/emojis", null, null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonEmojiArray).ConfigureAwait(false)).ToDictionary(e => e.Id.GetValueOrDefault(), e => new ApplicationEmoji(e, applicationId, this)); + + [GenerateAlias([typeof(Application)], nameof(Application.Id))] + [GenerateAlias([typeof(ApplicationEmoji)], nameof(ApplicationEmoji.ApplicationId), nameof(ApplicationEmoji.Id))] + public async Task GetApplicationEmojiAsync(ulong applicationId, ulong emojiId, RestRequestProperties? properties = null) + => new(await (await SendRequestAsync(HttpMethod.Get, $"/applications/{applicationId}/emojis/{emojiId}", null, null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonEmoji).ConfigureAwait(false), applicationId, this); + + [GenerateAlias([typeof(Application)], nameof(Application.Id))] + public async Task CreateApplicationEmojiAsync(ulong applicationId, ApplicationEmojiProperties applicationEmojiProperties, RestRequestProperties? properties = null) + { + using (HttpContent content = new JsonContent(applicationEmojiProperties, Serialization.Default.ApplicationEmojiProperties)) + return new(await (await SendRequestAsync(HttpMethod.Post, content, $"/applications/{applicationId}/emojis", null, null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonEmoji).ConfigureAwait(false), applicationId, this); + } + + [GenerateAlias([typeof(Application)], nameof(Application.Id))] + [GenerateAlias([typeof(ApplicationEmoji)], nameof(ApplicationEmoji.ApplicationId), nameof(ApplicationEmoji.Id))] + public async Task ModifyApplicationEmojiAsync(ulong applicationId, ulong emojiId, Action action, RestRequestProperties? properties = null) + { + ApplicationEmojiOptions applicationEmojiOptions = new(); + action(applicationEmojiOptions); + using (HttpContent content = new JsonContent(applicationEmojiOptions, Serialization.Default.ApplicationEmojiOptions)) + return new(await (await SendRequestAsync(HttpMethod.Patch, content, $"/applications/{applicationId}/emojis/{emojiId}", null, null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonEmoji).ConfigureAwait(false), applicationId, this); + } + + [GenerateAlias([typeof(Application)], nameof(Application.Id))] + [GenerateAlias([typeof(ApplicationEmoji)], nameof(ApplicationEmoji.ApplicationId), nameof(ApplicationEmoji.Id))] + public Task DeleteApplicationEmojiAsync(ulong applicationId, ulong emojiId, RestRequestProperties? properties = null) + => SendRequestAsync(HttpMethod.Delete, $"/applications/{applicationId}/emojis/{emojiId}", null, null, properties); } diff --git a/NetCord/Rest/RestClient.Guild.cs b/NetCord/Rest/RestClient.Guild.cs index 6f1ca5b58..c729a3868 100644 --- a/NetCord/Rest/RestClient.Guild.cs +++ b/NetCord/Rest/RestClient.Guild.cs @@ -240,8 +240,8 @@ public async Task> GetGuildVoiceRegionsAsync(ulong guil => (await (await SendRequestAsync(HttpMethod.Get, $"/guilds/{guildId}/regions", null, new(guildId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonVoiceRegionArray).ConfigureAwait(false)).Select(r => new VoiceRegion(r)); [GenerateAlias([typeof(RestGuild)], nameof(RestGuild.Id), TypeNameOverride = nameof(Guild))] - public async Task> GetGuildInvitesAsync(ulong guildId, RestRequestProperties? properties = null) - => (await (await SendRequestAsync(HttpMethod.Get, $"/guilds/{guildId}/invites", null, new(guildId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestGuildInviteArray).ConfigureAwait(false)).Select(i => new RestGuildInvite(i, this)); + public async Task> GetGuildInvitesAsync(ulong guildId, RestRequestProperties? properties = null) + => (await (await SendRequestAsync(HttpMethod.Get, $"/guilds/{guildId}/invites", null, new(guildId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestInviteArray).ConfigureAwait(false)).Select(i => new RestInvite(i, this)); [GenerateAlias([typeof(RestGuild)], nameof(RestGuild.Id), TypeNameOverride = nameof(Guild))] public async Task> GetGuildIntegrationsAsync(ulong guildId, RestRequestProperties? properties = null) diff --git a/NetCord/Rest/RestClient.Invite.cs b/NetCord/Rest/RestClient.Invite.cs index 1374084c6..c152ea762 100644 --- a/NetCord/Rest/RestClient.Invite.cs +++ b/NetCord/Rest/RestClient.Invite.cs @@ -4,16 +4,16 @@ namespace NetCord.Rest; public partial class RestClient { - [GenerateAlias([typeof(RestGuildInvite)], nameof(RestGuildInvite.Code), TypeNameOverride = nameof(GuildInvite))] - public async Task GetGuildInviteAsync(string inviteCode, bool withCounts = false, bool withExpiration = false, ulong? guildScheduledEventId = null, RestRequestProperties? properties = null) + [GenerateAlias([typeof(RestInvite)], nameof(RestInvite.Code), TypeNameOverride = nameof(Invite))] + public async Task GetGuildInviteAsync(string inviteCode, bool withCounts = false, bool withExpiration = false, ulong? guildScheduledEventId = null, RestRequestProperties? properties = null) { if (guildScheduledEventId.HasValue) - return new(await (await SendRequestAsync(HttpMethod.Get, $"/invites/{inviteCode}", $"?with_counts={withCounts}&with_expiration={withExpiration}&guild_scheduled_event_id={guildScheduledEventId}", null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestGuildInvite).ConfigureAwait(false), this); + return new(await (await SendRequestAsync(HttpMethod.Get, $"/invites/{inviteCode}", $"?with_counts={withCounts}&with_expiration={withExpiration}&guild_scheduled_event_id={guildScheduledEventId}", null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestInvite).ConfigureAwait(false), this); else - return new(await (await SendRequestAsync(HttpMethod.Get, $"/invites/{inviteCode}", $"?with_counts={withCounts}&with_expiration={withExpiration}", null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestGuildInvite).ConfigureAwait(false), this); + return new(await (await SendRequestAsync(HttpMethod.Get, $"/invites/{inviteCode}", $"?with_counts={withCounts}&with_expiration={withExpiration}", null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestInvite).ConfigureAwait(false), this); } - [GenerateAlias([typeof(RestGuildInvite)], nameof(RestGuildInvite.Code), TypeNameOverride = nameof(GuildInvite))] - public async Task DeleteGuildInviteAsync(string inviteCode, RestRequestProperties? properties = null) - => new(await (await SendRequestAsync(HttpMethod.Delete, $"/invites/{inviteCode}", null, null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestGuildInvite).ConfigureAwait(false), this); + [GenerateAlias([typeof(RestInvite)], nameof(RestInvite.Code), TypeNameOverride = nameof(Invite))] + public async Task DeleteGuildInviteAsync(string inviteCode, RestRequestProperties? properties = null) + => new(await (await SendRequestAsync(HttpMethod.Delete, $"/invites/{inviteCode}", null, null, properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonRestInvite).ConfigureAwait(false), this); } diff --git a/NetCord/Rest/RestClient.Poll.cs b/NetCord/Rest/RestClient.Poll.cs index edfe1494f..28133d85d 100644 --- a/NetCord/Rest/RestClient.Poll.cs +++ b/NetCord/Rest/RestClient.Poll.cs @@ -5,7 +5,7 @@ namespace NetCord.Rest; public partial class RestClient { [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = nameof(Message))] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = nameof(Message))] public IAsyncEnumerable GetMessagePollAnswerVotersAsync(ulong channelId, ulong messageId, int answerId, PaginationProperties? paginationProperties = null, RestRequestProperties? properties = null) { paginationProperties = PaginationProperties.PrepareWithDirectionValidation(paginationProperties, PaginationDirection.After, 100); @@ -23,7 +23,7 @@ public IAsyncEnumerable GetMessagePollAnswerVotersAsync(ulong channelId, u } [GenerateAlias([typeof(TextChannel)], nameof(TextChannel.Id))] - [GenerateAlias([typeof(RestMessage), typeof(IPartialMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = nameof(Message))] + [GenerateAlias([typeof(RestMessage)], nameof(RestMessage.ChannelId), nameof(RestMessage.Id), TypeNameOverride = nameof(Message))] public async Task EndMessagePollAsync(ulong channelId, ulong messageId, RestRequestProperties? properties = null) => new(await (await SendRequestAsync(HttpMethod.Post, $"/channels/{channelId}/polls/{messageId}/expire", null, new(channelId), properties).ConfigureAwait(false)).ToObjectAsync(Serialization.Default.JsonMessage).ConfigureAwait(false), this); } diff --git a/NetCord/Rest/RestGuildInvite.cs b/NetCord/Rest/RestGuildInvite.cs index 0b858918b..1f35bb5f3 100644 --- a/NetCord/Rest/RestGuildInvite.cs +++ b/NetCord/Rest/RestGuildInvite.cs @@ -1,12 +1,14 @@ namespace NetCord.Rest; -public partial class RestGuildInvite : IGuildInvite, IJsonModel +public partial class RestInvite : IInvite, IJsonModel { - JsonModels.JsonRestGuildInvite IJsonModel.JsonModel => _jsonModel; - private readonly JsonModels.JsonRestGuildInvite _jsonModel; + JsonModels.JsonRestInvite IJsonModel.JsonModel => _jsonModel; + private readonly JsonModels.JsonRestInvite _jsonModel; private readonly RestClient _client; + public InviteType Type => _jsonModel.Type; + public string Code => _jsonModel.Code; public RestGuild? Guild { get; } @@ -15,7 +17,7 @@ public partial class RestGuildInvite : IGuildInvite, IJsonModel _jsonModel.TargetType; + public InviteTargetType? TargetType => _jsonModel.TargetType; public User? TargetUser { get; } @@ -41,11 +43,11 @@ public partial class RestGuildInvite : IGuildInvite, IJsonModel _jsonModel.CreatedAt; - ulong? IGuildInvite.GuildId => Guild?.Id; + ulong? IInvite.GuildId => Guild?.Id; - ulong? IGuildInvite.ChannelId => Channel?.Id; + ulong? IInvite.ChannelId => Channel?.Id; - public RestGuildInvite(JsonModels.JsonRestGuildInvite jsonModel, RestClient client) + public RestInvite(JsonModels.JsonRestInvite jsonModel, RestClient client) { _jsonModel = jsonModel; diff --git a/NetCord/Rest/RestMessage.cs b/NetCord/Rest/RestMessage.cs index 4419655f5..00897add6 100644 --- a/NetCord/Rest/RestMessage.cs +++ b/NetCord/Rest/RestMessage.cs @@ -46,8 +46,14 @@ public RestMessage(NetCord.JsonModels.JsonMessage jsonModel, RestClient client) var messageReference = jsonModel.MessageReference; if (messageReference is not null) + { MessageReference = new(messageReference); + MessageSnapshots = jsonModel.MessageSnapshots.SelectOrEmpty(s => new MessageSnapshot(s, messageReference.GuildId, client)).ToArray(); + } + else + MessageSnapshots = []; + var referencedMessage = jsonModel.ReferencedMessage; if (referencedMessage is not null) ReferencedMessage = new(referencedMessage, client); @@ -80,6 +86,10 @@ public RestMessage(NetCord.JsonModels.JsonMessage jsonModel, RestClient client) var poll = jsonModel.Poll; if (poll is not null) Poll = new(poll); + + var call = jsonModel.Call; + if (call is not null) + Call = new(call); } /// @@ -188,15 +198,20 @@ public RestMessage(NetCord.JsonModels.JsonMessage jsonModel, RestClient client) /// public ulong? ApplicationId => _jsonModel.ApplicationId; + /// + /// A object indicating the message's applied flags. + /// + public MessageFlags Flags => _jsonModel.Flags.GetValueOrDefault(); + /// /// Contains data showing the source of a crosspost, channel follow add, pin, or message reply. /// public MessageReference? MessageReference { get; } /// - /// A object indicating the message's applied flags. + /// A list of messages associated with the message reference. /// - public MessageFlags Flags => _jsonModel.Flags.GetValueOrDefault(); + public IReadOnlyList MessageSnapshots { get; } /// /// The message associated with the . @@ -250,6 +265,8 @@ public RestMessage(NetCord.JsonModels.JsonMessage jsonModel, RestClient client) public MessagePoll? Poll { get; } + public MessageCall? Call { get; } + public Task ReplyAsync(ReplyMessageProperties replyMessage, RestRequestProperties? properties = null) => SendAsync(replyMessage.ToMessageProperties(Id), properties); } diff --git a/NetCord/Rest/WebhookMessageProperties.cs b/NetCord/Rest/WebhookMessageProperties.cs index 9550eeef9..38cc920c1 100644 --- a/NetCord/Rest/WebhookMessageProperties.cs +++ b/NetCord/Rest/WebhookMessageProperties.cs @@ -2,7 +2,7 @@ namespace NetCord.Rest; -public partial class WebhookMessageProperties : IHttpSerializable +public partial class WebhookMessageProperties : IHttpSerializable, IMessageProperties { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonPropertyName("content")] diff --git a/NetCord/Serialization.cs b/NetCord/Serialization.cs index 89c160bd3..42a33f23f 100644 --- a/NetCord/Serialization.cs +++ b/NetCord/Serialization.cs @@ -39,8 +39,8 @@ namespace NetCord; [JsonSerializable(typeof(JsonMessageReactionAddEventArgs))] [JsonSerializable(typeof(JsonMessageDeleteBulkEventArgs))] [JsonSerializable(typeof(JsonMessageDeleteEventArgs))] -[JsonSerializable(typeof(JsonGuildInviteDeleteEventArgs))] -[JsonSerializable(typeof(JsonGuildInvite))] +[JsonSerializable(typeof(JsonInviteDeleteEventArgs))] +[JsonSerializable(typeof(JsonInvite))] [JsonSerializable(typeof(JsonInteraction))] [JsonSerializable(typeof(JsonGuildIntegrationDeleteEventArgs))] [JsonSerializable(typeof(JsonIntegration))] @@ -96,6 +96,7 @@ namespace NetCord; [JsonSerializable(typeof(MessageCommandProperties))] [JsonSerializable(typeof(ButtonProperties))] [JsonSerializable(typeof(LinkButtonProperties))] +[JsonSerializable(typeof(PremiumButtonProperties))] [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(StringMenuProperties))] [JsonSerializable(typeof(UserMenuProperties))] @@ -125,9 +126,9 @@ namespace NetCord; [JsonSerializable(typeof(JsonUser[]))] [JsonSerializable(typeof(BulkDeleteMessagesProperties))] [JsonSerializable(typeof(PermissionOverwriteProperties))] -[JsonSerializable(typeof(JsonRestGuildInvite[]))] -[JsonSerializable(typeof(GuildInviteProperties))] -[JsonSerializable(typeof(JsonRestGuildInvite))] +[JsonSerializable(typeof(JsonRestInvite[]))] +[JsonSerializable(typeof(InviteProperties))] +[JsonSerializable(typeof(JsonRestInvite))] [JsonSerializable(typeof(FollowAnnouncementGuildChannelProperties))] [JsonSerializable(typeof(JsonFollowedChannel))] [JsonSerializable(typeof(GroupDMChannelUserAddProperties))] @@ -138,7 +139,9 @@ namespace NetCord; [JsonSerializable(typeof(JsonEmoji[]))] [JsonSerializable(typeof(JsonEmoji))] [JsonSerializable(typeof(GuildEmojiProperties))] +[JsonSerializable(typeof(ApplicationEmojiProperties))] [JsonSerializable(typeof(GuildEmojiOptions))] +[JsonSerializable(typeof(ApplicationEmojiOptions))] [JsonSerializable(typeof(JsonGateway))] [JsonSerializable(typeof(JsonGatewayBot))] [JsonSerializable(typeof(GuildProperties))] diff --git a/NetCord/TeamRole.cs b/NetCord/TeamRole.cs index 1f60e08a3..62a890c02 100644 --- a/NetCord/TeamRole.cs +++ b/NetCord/TeamRole.cs @@ -8,7 +8,7 @@ namespace NetCord; /// /// The Owner role is not represented in the enum, as it is not represented in 's field. Instead, owners can be identified using a 's field. They have the most permissive role, and can take destructive, irreversible actions like deleting team-owned apps or the team itself. Teams are limited to 1 owner. /// -[JsonConverter(typeof(JsonConverters.StringEnumConverterWithErrorHandling))] +[JsonConverter(typeof(JsonConverters.SafeStringEnumConverter))] public enum TeamRole { /// diff --git a/NetCord/User.cs b/NetCord/User.cs index 36870978e..90c14cc4c 100644 --- a/NetCord/User.cs +++ b/NetCord/User.cs @@ -8,10 +8,19 @@ namespace NetCord; /// /// Users in Discord are generally considered the base entity and can be members of guilds, participate in text and voice chat, and much more. Users are separated by a distinction of 'bot' vs 'normal'. Bot users are automated users that are 'owned' by another user. /// -public partial class User(JsonModels.JsonUser jsonModel, RestClient client) : ClientEntity(client), IJsonModel +public partial class User : ClientEntity, IJsonModel { JsonModels.JsonUser IJsonModel.JsonModel => _jsonModel; - private protected readonly JsonModels.JsonUser _jsonModel = jsonModel; + private protected readonly JsonModels.JsonUser _jsonModel; + + public User(JsonModels.JsonUser jsonModel, RestClient client) : base(client) + { + _jsonModel = jsonModel; + + var avatarDecorationData = jsonModel.AvatarDecorationData; + if (avatarDecorationData is not null) + AvatarDecorationData = new(avatarDecorationData); + } /// /// The user's ID. @@ -176,12 +185,12 @@ public partial class User(JsonModels.JsonUser jsonModel, RestClient client) : Cl public UserFlags? PublicFlags => _jsonModel.PublicFlags; /// - /// The user's avatar decoration hash. + /// Data for the user's avatar decoration. /// /// /// Requires the identify OAuth2 scope. /// - public string? AvatarDecorationHash => _jsonModel.AvatarDecorationHash; + public AvatarDecorationData? AvatarDecorationData { get; } /// /// Whether the user has a set custom avatar. @@ -210,13 +219,13 @@ public partial class User(JsonModels.JsonUser jsonModel, RestClient client) : Cl /// /// Whether the user has a set avatar decoration. /// - public bool HasAvatarDecoration => AvatarDecorationHash is not null; + public bool HasAvatarDecoration => AvatarDecorationData is not null; /// - /// Gets the of the user's avatar decoration URL. + /// Gets the of the user's avatar decoration. /// /// An pointing to the user's avatar decoration. If the user does not have one set, returns . - public ImageUrl? GetAvatarDecorationUrl() => AvatarDecorationHash is string hash ? ImageUrl.UserAvatarDecoration(Id, hash) : null; + public ImageUrl? GetAvatarDecorationUrl() => AvatarDecorationData is { Hash: var hash } ? ImageUrl.AvatarDecoration(hash) : null; /// /// Returns an object representing the user's default avatar. diff --git a/NetCord/UserStatusType.cs b/NetCord/UserStatusType.cs index 9c103a323..75a5f90b7 100644 --- a/NetCord/UserStatusType.cs +++ b/NetCord/UserStatusType.cs @@ -2,7 +2,7 @@ namespace NetCord; -[JsonConverter(typeof(JsonConverters.StringEnumConverterWithErrorHandling))] +[JsonConverter(typeof(JsonConverters.SafeStringEnumConverter))] public enum UserStatusType { [JsonPropertyName("online")] diff --git a/SourceGenerators/RestClientMethodAliasesGenerator/RestClientMethodAliasesGenerator.cs b/SourceGenerators/RestClientMethodAliasesGenerator/RestClientMethodAliasesGenerator.cs index 2a9ed6b54..c3c1c8452 100644 --- a/SourceGenerators/RestClientMethodAliasesGenerator/RestClientMethodAliasesGenerator.cs +++ b/SourceGenerators/RestClientMethodAliasesGenerator/RestClientMethodAliasesGenerator.cs @@ -25,7 +25,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) namespace NetCord.Rest; [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] - public class GenerateAliasAttribute(Type[] types, params string?[] parameterAliases) : Attribute + internal class GenerateAliasAttribute(Type[] types, params string?[] parameterAliases) : Attribute { public Type[] Types { get; } = types; diff --git a/Tests/NetCord.Test.Hosting/CustomSlashCommandResultHandler.cs b/Tests/NetCord.Test.Hosting/CustomSlashCommandResultHandler.cs new file mode 100644 index 000000000..51b0dfce8 --- /dev/null +++ b/Tests/NetCord.Test.Hosting/CustomSlashCommandResultHandler.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Logging; + +using NetCord.Gateway; +using NetCord.Hosting.Services.ApplicationCommands; +using NetCord.Services; +using NetCord.Services.ApplicationCommands; + +namespace NetCord.Test.Hosting; + +internal class CustomSlashCommandResultHandler : IApplicationCommandResultHandler +{ + private static readonly ApplicationCommandResultHandler _defaultHandler = new(MessageFlags.Ephemeral); + + public ValueTask HandleResultAsync(IExecutionResult result, SlashCommandContext context, GatewayClient? client, ILogger logger, IServiceProvider services) + { + logger.LogInformation("Handling result of slash command"); + + return _defaultHandler.HandleResultAsync(result, context, client, logger, services); + } +} diff --git a/Tests/NetCord.Test.Hosting/Program.cs b/Tests/NetCord.Test.Hosting/Program.cs index 1536c37b1..f5e546def 100644 --- a/Tests/NetCord.Test.Hosting/Program.cs +++ b/Tests/NetCord.Test.Hosting/Program.cs @@ -39,7 +39,10 @@ { Intents = GatewayIntents.All, }) - .AddApplicationCommands() + .AddApplicationCommands(options => + { + options.ResultHandler = new CustomSlashCommandResultHandler(); + }) .AddApplicationCommands() .AddApplicationCommands() .AddComponentInteractions() diff --git a/Tests/NetCord.Test/Commands/Administrative/BanCommands.cs b/Tests/NetCord.Test/Commands/Administrative/BanCommands.cs index 79c718282..8b5d65892 100644 --- a/Tests/NetCord.Test/Commands/Administrative/BanCommands.cs +++ b/Tests/NetCord.Test/Commands/Administrative/BanCommands.cs @@ -25,7 +25,7 @@ public async Task Ban(UserId userId, TimeSpan deleteMessagesTime = default, [Com [ actionRow ], - MessageReference = new(Context.Message.Id), + MessageReference = MessageReferenceProperties.Reply(Context.Message.Id), AllowedMentions = AllowedMentionsProperties.None, }; await SendAsync(message); diff --git a/Tests/NetCord.Test/Commands/Administrative/MuteCommands.cs b/Tests/NetCord.Test/Commands/Administrative/MuteCommands.cs index c4e22350e..16b1439e3 100644 --- a/Tests/NetCord.Test/Commands/Administrative/MuteCommands.cs +++ b/Tests/NetCord.Test/Commands/Administrative/MuteCommands.cs @@ -23,7 +23,7 @@ public async Task Mute([CanManage] GuildUser user, TimeSpan time, [CommandParame [ actionRow ], - MessageReference = new(Context.Message.Id), + MessageReference = MessageReferenceProperties.Reply(Context.Message.Id), AllowedMentions = AllowedMentionsProperties.None, }; await SendAsync(message); diff --git a/Tests/NetCord.Test/Commands/NormalCommands.cs b/Tests/NetCord.Test/Commands/NormalCommands.cs index 293ef64c6..6c789c895 100644 --- a/Tests/NetCord.Test/Commands/NormalCommands.cs +++ b/Tests/NetCord.Test/Commands/NormalCommands.cs @@ -21,7 +21,7 @@ public Task Say([CommandParameter(Remainder = true)] ReadOnlyMemory text) [Command("reply")] public Task Reply([CommandParameter(Remainder = true)] string text) { - return SendAsync(new MessageProperties() { Content = text, AllowedMentions = AllowedMentionsProperties.None, MessageReference = new(Context.Message.Id) }); + return SendAsync(new MessageProperties() { Content = text, AllowedMentions = AllowedMentionsProperties.None, MessageReference = MessageReferenceProperties.Reply(Context.Message.Id) }); } [Command("roles")] @@ -106,7 +106,7 @@ public Task Avatar([CommandParameter(Remainder = true)] GuildUser? user = null) [ embed ], - MessageReference = new(Context.Message.Id, false), + MessageReference = MessageReferenceProperties.Reply(Context.Message.Id, false), AllowedMentions = new() { ReplyMention = false diff --git a/Tests/NetCord.Test/Commands/StrangeCommands.cs b/Tests/NetCord.Test/Commands/StrangeCommands.cs index f1ad7cfd3..28abb8c53 100644 --- a/Tests/NetCord.Test/Commands/StrangeCommands.cs +++ b/Tests/NetCord.Test/Commands/StrangeCommands.cs @@ -65,7 +65,7 @@ public Task Button() { Content = "This is button:", Components = [actionRow], - MessageReference = new(Context.Message.Id), + MessageReference = MessageReferenceProperties.Reply(Context.Message.Id), AllowedMentions = new() { ReplyMention = false @@ -89,7 +89,7 @@ public Task Link([CommandParameter(Remainder = true)] Uri url) actionRow ], Content = "This is the message with the link", - MessageReference = new(Context.Message.Id), + MessageReference = MessageReferenceProperties.Reply(Context.Message.Id), AllowedMentions = new() { ReplyMention = false @@ -114,7 +114,7 @@ public Task Dzejus() MessageProperties message = new() { Attachments = [file], - MessageReference = new(Context.Message.Id), + MessageReference = MessageReferenceProperties.Reply(Context.Message.Id), AllowedMentions = AllowedMentionsProperties.None }; return SendAsync(message); @@ -185,7 +185,7 @@ public Task Menu(params string[] values) { Content = "Here is your menu:", Components = [new StringMenuProperties("menu", values.Select(v => new StringMenuSelectOptionProperties(v, v))) { MaxValues = values.Length }], - MessageReference = new(Context.Message.Id) + MessageReference = MessageReferenceProperties.Reply(Context.Message.Id) }; return SendAsync(message); } @@ -269,7 +269,7 @@ public Task AttachmentAsync() return SendAsync(new() { Attachments = [attachment], - Embeds = [new() { Image = attachment }] + Embeds = [new() { Image = $"attachment://{attachment.FileName}" }] }); } diff --git a/Tests/NetCord.Test/localizations/localization.pl.pl.pl.json b/Tests/NetCord.Test/localizations/localization.pl.pl.pl.json deleted file mode 100644 index e9cf1b27a..000000000 --- a/Tests/NetCord.Test/localizations/localization.pl.pl.pl.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "commands": { - "ping": { - "description": "Sprawdza czy bot jest aktywny", - "parameters": { - "s": { - "name": "ś", - "description": "Wartość do zwrócenia" - } - } - }, - "permission-nested": { - "name": "permisja-zagnieżdżona", - "description": "Permisja", - "subcommands": { - "add": { - "name": "dodaj", - "description": "Dodaje permisję", - "parameters": { - "i": { - "name": "io" - }, - "permission": { - "name": "permisja", - "description": "Permisja do dodania" - } - } - }, - "remove": { - "name": "usuń", - "description": "Usuwa permisję" - }, - "list": { - "name": "lista", - "description": "Lista permisji", - "subcommands": { - "user": { - "name": "użytkownik", - "description": "Lista permisji użytkownika", - "parameters": { - "i": { - "name": "io" - }, - "permission": { - "name": "permisja", - "description": "Permisja do dodania" - } - } - }, - "role": { - "name": "rola", - "description": "Lista permisji roli" - } - } - } - } - } - }, - "enums": { - "NetCord.Test.SlashCommands.DeleteMessagesDays": { - "DontRemove": "Nie usuwaj", - "Last24Hours": "Ostatnie 24 godziny", - "Last2Days": "Ostatnie 2 dni", - "Last3Days": "Ostatnie 3 dni", - "Last4Days": "Ostatnie 4 dni", - "Last5Days": "Ostatnie 5 dni", - "Last6Days": "Ostatnie 6 dni", - "LastWeek": "Ostatni tydzień" - } - } -} From 09706e5d80e1615fd04f0cfaa10be9f7544d6536 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Fri, 23 Aug 2024 08:45:07 +0200 Subject: [PATCH 05/33] Implement basics --- NetCord/Gateway/GatewayClient.cs | 25 +- NetCord/Gateway/GatewayClientConfiguration.cs | 6 +- NetCord/Gateway/GatewayRateLimiter.cs | 45 ++++ NetCord/Gateway/IRateLimiter.cs | 8 + .../Gateway/IWebSocketClientConfiguration.cs | 4 +- NetCord/Gateway/NullRateLimiter.cs | 20 ++ NetCord/Gateway/RateLimitAcquisitionResult.cs | 18 ++ NetCord/Gateway/RentedArrayBufferWriter.cs | 2 +- NetCord/Gateway/ShardedGatewayClient.cs | 8 +- .../ShardedGatewayClientConfiguration.cs | 4 +- NetCord/Gateway/Voice/VoiceClient.cs | 10 +- .../Gateway/Voice/VoiceClientConfiguration.cs | 5 +- NetCord/Gateway/WebSocketClient.cs | 212 ++++++++++++++--- NetCord/Gateway/WebSocketPayloadProperties.cs | 10 + NetCord/Gateway/WebSocketRetryHandling.cs | 10 + NetCord/Gateway/WebSockets/IWebSocket.cs | 32 --- .../WebSockets/IWebSocketConnection.cs | 16 ++ .../IWebSocketConnectionProvider.cs | 6 + NetCord/Gateway/WebSockets/WebSocket.cs | 220 ------------------ .../Gateway/WebSockets/WebSocketConnection.cs | 50 ++++ .../WebSockets/WebSocketConnectionProvider.cs | 9 + .../WebSocketConnectionReceiveResult.cs | 27 +++ .../WebSockets/WebSocketMessageFlags.cs | 8 + .../WebSockets/WebSocketMessageType.cs | 8 + NetCord/Rest/RateLimitedException.cs | 2 +- Tests/NetCord.Test/Program.cs | 26 ++- 26 files changed, 478 insertions(+), 313 deletions(-) create mode 100644 NetCord/Gateway/GatewayRateLimiter.cs create mode 100644 NetCord/Gateway/IRateLimiter.cs create mode 100644 NetCord/Gateway/NullRateLimiter.cs create mode 100644 NetCord/Gateway/RateLimitAcquisitionResult.cs create mode 100644 NetCord/Gateway/WebSocketPayloadProperties.cs create mode 100644 NetCord/Gateway/WebSocketRetryHandling.cs delete mode 100644 NetCord/Gateway/WebSockets/IWebSocket.cs create mode 100644 NetCord/Gateway/WebSockets/IWebSocketConnection.cs create mode 100644 NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs delete mode 100644 NetCord/Gateway/WebSockets/WebSocket.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketConnection.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketConnectionReceiveResult.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs create mode 100644 NetCord/Gateway/WebSockets/WebSocketMessageType.cs diff --git a/NetCord/Gateway/GatewayClient.cs b/NetCord/Gateway/GatewayClient.cs index c7ec96135..2e54d278e 100644 --- a/NetCord/Gateway/GatewayClient.cs +++ b/NetCord/Gateway/GatewayClient.cs @@ -9,7 +9,7 @@ namespace NetCord.Gateway; /// -/// The GatewayClient class allows applications to send and receive data from the Discord Gateway, such as events and resource requests, via a WebSocket client. +/// The class allows applications to send and receive data from the Discord Gateway, such as events and resource requests. /// public partial class GatewayClient : WebSocketClient, IEntity { @@ -370,7 +370,7 @@ public partial class GatewayClient : WebSocketClient, IEntity public event Func? GuildUserUpdate; /// - /// Sent in response to . You can use the and to calculate how many chunks are left for your request.
+ /// Sent in response to . You can use the and to calculate how many chunks are left for your request.
///
/// ///
Required Intents: None @@ -847,7 +847,7 @@ private ValueTask SendIdentifyAsync(PresenceProperties? presence = null, Cancell Intents = _configuration.Intents, }).Serialize(Serialization.Default.GatewayPayloadPropertiesGatewayIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } /// @@ -887,14 +887,14 @@ private ValueTask TryResumeAsync(string sessionId, int sequenceNumber, Cancellat { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Resume, new(Token.RawToken, sessionId, sequenceNumber)).Serialize(Serialization.Default.GatewayPayloadPropertiesGatewayResumeProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } private protected override ValueTask HeartbeatAsync(CancellationToken cancellationToken = default) { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Heartbeat, SequenceNumber).Serialize(Serialization.Default.GatewayPayloadPropertiesInt32); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } private protected override JsonPayload CreatePayload(ReadOnlyMemory payload) => JsonSerializer.Deserialize(_compression.Decompress(payload).Span, Serialization.Default.JsonPayload)!; @@ -943,30 +943,31 @@ private protected override async Task ProcessPayloadAsync(JsonPayload payload) /// /// Joins, moves, or disconnects the app from a voice channel. /// - public ValueTask UpdateVoiceStateAsync(VoiceStateProperties voiceState, CancellationToken cancellationToken = default) + public ValueTask UpdateVoiceStateAsync(VoiceStateProperties voiceState, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { GatewayPayloadProperties payload = new(GatewayOpcode.VoiceStateUpdate, voiceState); - return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesVoiceStateProperties), cancellationToken); + return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesVoiceStateProperties), properties, cancellationToken); } /// /// Updates an app's presence. /// /// The presence to set. + /// /// The cancellation token to cancel the operation. - public ValueTask UpdatePresenceAsync(PresenceProperties presence, CancellationToken cancellationToken = default) + public ValueTask UpdatePresenceAsync(PresenceProperties presence, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { GatewayPayloadProperties payload = new(GatewayOpcode.PresenceUpdate, presence); - return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesPresenceProperties), cancellationToken); + return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesPresenceProperties), properties, cancellationToken); } /// - /// Requests user for a guild. + /// Requests users for a guild. /// - public ValueTask RequestGuildUsersAsync(GuildUsersRequestProperties requestProperties, CancellationToken cancellationToken = default) + public ValueTask RequestGuildUsersAsync(GuildUsersRequestProperties requestProperties, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { GatewayPayloadProperties payload = new(GatewayOpcode.RequestGuildUsers, requestProperties); - return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesGuildUsersRequestProperties), cancellationToken); + return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesGuildUsersRequestProperties), properties, cancellationToken); } private async Task ProcessEventAsync(JsonPayload payload) diff --git a/NetCord/Gateway/GatewayClientConfiguration.cs b/NetCord/Gateway/GatewayClientConfiguration.cs index 2621c882b..7edba9551 100644 --- a/NetCord/Gateway/GatewayClientConfiguration.cs +++ b/NetCord/Gateway/GatewayClientConfiguration.cs @@ -7,7 +7,9 @@ namespace NetCord.Gateway; public class GatewayClientConfiguration : IWebSocketClientConfiguration { - public IWebSocket? WebSocket { get; init; } + public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; init; } + public IRateLimiter? RateLimiter { get; init; } + public WebSocketPayloadProperties? DefaultPayloadProperties { get; init; } public IReconnectStrategy? ReconnectStrategy { get; init; } public ILatencyTimer? LatencyTimer { get; init; } public ApiVersion Version { get; init; } = ApiVersion.V10; @@ -21,4 +23,6 @@ public class GatewayClientConfiguration : IWebSocketClientConfiguration public Shard? Shard { get; init; } public bool CacheDMChannels { get; init; } = true; public Rest.RestClientConfiguration? RestClientConfiguration { get; init; } + + IRateLimiter? IWebSocketClientConfiguration.RateLimiter => RateLimiter is { } rateLimiter ? rateLimiter : new GatewayRateLimiter(120, 60_000); } diff --git a/NetCord/Gateway/GatewayRateLimiter.cs b/NetCord/Gateway/GatewayRateLimiter.cs new file mode 100644 index 000000000..433ae1bee --- /dev/null +++ b/NetCord/Gateway/GatewayRateLimiter.cs @@ -0,0 +1,45 @@ +namespace NetCord.Gateway; + +public sealed class GatewayRateLimiter(int limit, long duration) : IRateLimiter +{ + private readonly object _lock = new(); + private readonly int _limit = limit; + private int _remaining = limit; + private long _reset; + + public ValueTask TryAcquireAsync() + { + var timestamp = Environment.TickCount64; + lock (_lock) + { + var diff = _reset - timestamp; + if (diff <= 0) + { + _remaining = _limit - 1; + _reset = timestamp + duration; + } + else + { + if (_remaining == 0) + return new(RateLimitAcquisitionResult.RateLimit((int)diff)); + else + _remaining--; + } + } + + return new(RateLimitAcquisitionResult.NoRateLimit()); + } + + public void Reset() + { + lock (_lock) + { + _remaining = _limit; + _reset = 0; + } + } + + public void Dispose() + { + } +} diff --git a/NetCord/Gateway/IRateLimiter.cs b/NetCord/Gateway/IRateLimiter.cs new file mode 100644 index 000000000..c7a25ace4 --- /dev/null +++ b/NetCord/Gateway/IRateLimiter.cs @@ -0,0 +1,8 @@ +namespace NetCord.Gateway; + +public interface IRateLimiter : IDisposable +{ + public ValueTask TryAcquireAsync(); + + public void Reset(); +} diff --git a/NetCord/Gateway/IWebSocketClientConfiguration.cs b/NetCord/Gateway/IWebSocketClientConfiguration.cs index 10b12b1a3..131f763e3 100644 --- a/NetCord/Gateway/IWebSocketClientConfiguration.cs +++ b/NetCord/Gateway/IWebSocketClientConfiguration.cs @@ -6,7 +6,9 @@ namespace NetCord.Gateway; internal interface IWebSocketClientConfiguration { - public IWebSocket? WebSocket { get; } + public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; } public IReconnectStrategy? ReconnectStrategy { get; } public ILatencyTimer? LatencyTimer { get; } + public IRateLimiter? RateLimiter { get; } + public WebSocketPayloadProperties? DefaultPayloadProperties { get; } } diff --git a/NetCord/Gateway/NullRateLimiter.cs b/NetCord/Gateway/NullRateLimiter.cs new file mode 100644 index 000000000..f03ad4929 --- /dev/null +++ b/NetCord/Gateway/NullRateLimiter.cs @@ -0,0 +1,20 @@ +namespace NetCord.Gateway; + +internal sealed class NullRateLimiter : IRateLimiter +{ + public static NullRateLimiter Instance { get; } = new(); + + private NullRateLimiter() + { + } + + public ValueTask TryAcquireAsync() => new(RateLimitAcquisitionResult.NoRateLimit()); + + public void Reset() + { + } + + public void Dispose() + { + } +} diff --git a/NetCord/Gateway/RateLimitAcquisitionResult.cs b/NetCord/Gateway/RateLimitAcquisitionResult.cs new file mode 100644 index 000000000..f3c97a851 --- /dev/null +++ b/NetCord/Gateway/RateLimitAcquisitionResult.cs @@ -0,0 +1,18 @@ +namespace NetCord.Gateway; + +public readonly struct RateLimitAcquisitionResult +{ + private RateLimitAcquisitionResult(int resetAfter, bool rateLimited) + { + ResetAfter = resetAfter; + RateLimited = rateLimited; + } + + public static RateLimitAcquisitionResult NoRateLimit() => new(0, false); + + public static RateLimitAcquisitionResult RateLimit(int resetAfter) => new(resetAfter, true); + + public int ResetAfter { get; } + + public bool RateLimited { get; } +} diff --git a/NetCord/Gateway/RentedArrayBufferWriter.cs b/NetCord/Gateway/RentedArrayBufferWriter.cs index eb4ceddfb..964ce239a 100644 --- a/NetCord/Gateway/RentedArrayBufferWriter.cs +++ b/NetCord/Gateway/RentedArrayBufferWriter.cs @@ -57,7 +57,7 @@ private void ResizeBuffer(int sizeHint) { var pool = ArrayPool.Shared; var newBuffer = pool.Rent(sum); - Array.Copy(buffer, newBuffer, index); + buffer.AsSpan(0, index).CopyTo(newBuffer); _buffer = newBuffer; pool.Return(buffer); } diff --git a/NetCord/Gateway/ShardedGatewayClient.cs b/NetCord/Gateway/ShardedGatewayClient.cs index 8c9dca6ed..5f1b11bc4 100644 --- a/NetCord/Gateway/ShardedGatewayClient.cs +++ b/NetCord/Gateway/ShardedGatewayClient.cs @@ -31,7 +31,7 @@ private static ShardedGatewayClientConfiguration CreateConfiguration(ShardedGate { return new() { - WebSocketFactory = _ => null, + WebSocketConnectionProviderFactory = _ => null, ReconnectStrategyFactory = _ => null, LatencyTimerFactory = _ => null, VersionFactory = _ => ApiVersion.V10, @@ -49,7 +49,7 @@ private static ShardedGatewayClientConfiguration CreateConfiguration(ShardedGate return new() { - WebSocketFactory = configuration.WebSocketFactory ?? (_ => null), + WebSocketConnectionProviderFactory = configuration.WebSocketConnectionProviderFactory ?? (_ => null), ReconnectStrategyFactory = configuration.ReconnectStrategyFactory ?? (_ => null), LatencyTimerFactory = configuration.LatencyTimerFactory ?? (_ => null), VersionFactory = configuration.VersionFactory ?? (_ => ApiVersion.V10), @@ -219,7 +219,9 @@ private GatewayClientConfiguration GetGatewayClientConfiguration(Shard shard) var configuration = _configuration; return new() { - WebSocket = configuration.WebSocketFactory!(shard), + WebSocketConnectionProvider = configuration.WebSocketConnectionProviderFactory!(shard), + RateLimiter = configuration.RateLimiterFactory!(shard), + DefaultPayloadProperties = configuration.DefaultPayloadPropertiesFactory!(shard), ReconnectStrategy = configuration.ReconnectStrategyFactory!(shard), LatencyTimer = configuration.LatencyTimerFactory!(shard), Version = configuration.VersionFactory!(shard), diff --git a/NetCord/Gateway/ShardedGatewayClientConfiguration.cs b/NetCord/Gateway/ShardedGatewayClientConfiguration.cs index 0cce73dd5..24718ee9c 100644 --- a/NetCord/Gateway/ShardedGatewayClientConfiguration.cs +++ b/NetCord/Gateway/ShardedGatewayClientConfiguration.cs @@ -7,7 +7,9 @@ namespace NetCord.Gateway; public class ShardedGatewayClientConfiguration { - public Func? WebSocketFactory { get; init; } + public Func? WebSocketConnectionProviderFactory { get; init; } + public Func? RateLimiterFactory { get; init; } + public Func? DefaultPayloadPropertiesFactory { get; init; } public Func? ReconnectStrategyFactory { get; init; } public Func? LatencyTimerFactory { get; init; } public Func? VersionFactory { get; init; } diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index 06a58acf7..c42cfb6e4 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -56,7 +56,7 @@ private ValueTask SendIdentifyAsync(CancellationToken cancellationToken = defaul { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Identify, new(GuildId, UserId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } /// @@ -86,14 +86,14 @@ private protected override ValueTask TryResumeAsync(CancellationToken cancellati { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Resume, new(GuildId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceResumeProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } private protected override ValueTask HeartbeatAsync(CancellationToken cancellationToken = default) { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Heartbeat, Environment.TickCount).Serialize(Serialization.Default.VoicePayloadPropertiesInt32); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, cancellationToken); + return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); } private protected override async Task ProcessPayloadAsync(JsonPayload payload) @@ -240,10 +240,10 @@ private async void HandleDatagramReceive(UdpReceiveResult obj) } } - public ValueTask EnterSpeakingStateAsync(SpeakingFlags flags, int delay = 0, CancellationToken cancellationToken = default) + public ValueTask EnterSpeakingStateAsync(SpeakingFlags flags, int delay = 0, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { VoicePayloadProperties payload = new(VoiceOpcode.Speaking, new(flags, delay, Cache.Ssrc)); - return SendPayloadAsync(payload.Serialize(Serialization.Default.VoicePayloadPropertiesSpeakingProperties), cancellationToken); + return SendPayloadAsync(payload.Serialize(Serialization.Default.VoicePayloadPropertiesSpeakingProperties), properties, cancellationToken); } /// diff --git a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs index fa8c8ecce..5d581f42b 100644 --- a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs +++ b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs @@ -8,7 +8,8 @@ namespace NetCord.Gateway.Voice; public class VoiceClientConfiguration : IWebSocketClientConfiguration { - public IWebSocket? WebSocket { get; init; } + public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; init; } + public WebSocketPayloadProperties? DefaultPayloadProperties { get; init; } public IUdpSocket? UdpSocket { get; init; } public IReconnectStrategy? ReconnectStrategy { get; init; } public ILatencyTimer? LatencyTimer { get; init; } @@ -16,4 +17,6 @@ public class VoiceClientConfiguration : IWebSocketClientConfiguration public IVoiceClientCache? Cache { get; init; } public IVoiceEncryption? Encryption { get; init; } public bool RedirectInputStreams { get; init; } + + IRateLimiter? IWebSocketClientConfiguration.RateLimiter => null; } diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 6e64c07c6..9cd1bebca 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -12,30 +12,68 @@ namespace NetCord.Gateway; public abstract class WebSocketClient : IDisposable { - private protected WebSocketClient(IWebSocketClientConfiguration configuration) + private sealed class State(IWebSocketConnection connection) : IDisposable { - var webSocket = configuration.WebSocket ?? new WebSocket(); + public IWebSocketConnection Connection { get; } = connection; + + public CancellationTokenProvider DisconnectedTokenProvider { get; } = new(); + + public Task ReadTask => _readCompletionSource.Task; + + public TaskCompletionSource _readCompletionSource = new(); + + public Task ReadyTask => _readCompletionSource.Task; + + public TaskCompletionSource _readyCompletionSource = new(); + + private int _state; + + public async void StartReading(Func readAsync) + { + await readAsync(this).ConfigureAwait(false); + + _readCompletionSource.TrySetResult(); + } - webSocket.Connecting += HandleConnecting; - webSocket.Connected += HandleConnected; - webSocket.Disconnected += HandleDisconnected; - webSocket.Closed += HandleClosed; - webSocket.MessageReceived += HandleMessageReceived; + public bool TryIndicateDisconnecting() + { + var disconnecting = Interlocked.Exchange(ref _state, 1) is 0; + + if (disconnecting) + DisconnectedTokenProvider.Cancel(); + + return disconnecting; + } - _webSocket = webSocket; + public void Dispose() + { + DisconnectedTokenProvider.Dispose(); + Connection.Dispose(); + } + } + + private const int DefaultBufferSize = 8192; + + private protected WebSocketClient(IWebSocketClientConfiguration configuration) + { + _connectionProvider = configuration.WebSocketConnectionProvider ?? new WebSocketConnectionProvider(); _reconnectStrategy = configuration.ReconnectStrategy ?? new ReconnectStrategy(); _latencyTimer = configuration.LatencyTimer ?? new LatencyTimer(); + _rateLimiter = configuration.RateLimiter ?? NullRateLimiter.Instance; + _defaultPayloadProperties = configuration.DefaultPayloadProperties is { } defaultPayloadProperties ? defaultPayloadProperties with { } : new(); } private readonly object _eventsLock = new(); - private readonly IWebSocket _webSocket; + private readonly IWebSocketConnectionProvider _connectionProvider; private readonly IReconnectStrategy _reconnectStrategy; + private readonly IRateLimiter _rateLimiter; + private readonly WebSocketPayloadProperties _defaultPayloadProperties; private protected readonly ILatencyTimer _latencyTimer; private protected readonly TaskCompletionSource _readyCompletionSource = new(); - private CancellationTokenProvider? _disconnectedTokenProvider; private CancellationTokenProvider? _closedTokenProvider; + private State? _state; private protected abstract Uri Uri { get; } @@ -68,8 +106,6 @@ private async void HandleConnecting() private async void HandleConnected() { - Interlocked.Exchange(ref _disconnectedTokenProvider, new())?.Cancel(); - OnConnected(); InvokeLog(LogMessage.Info("Connected")); await InvokeEventAsync(Connect).ConfigureAwait(false); @@ -77,9 +113,7 @@ private async void HandleConnected() private async void HandleDisconnected(WebSocketCloseStatus? closeStatus, string? description) { - Interlocked.Exchange(ref _disconnectedTokenProvider, null)?.Cancel(); - - InvokeLog(string.IsNullOrEmpty(description) ? LogMessage.Info("Disconnected") : LogMessage.Info("Disconnected", description.EndsWith('.') ? description[..^1] : description)); + InvokeLog(LogMessage.Info("Disconnected", string.IsNullOrEmpty(description) ? null : (description.EndsWith('.') ? description[..^1] : description))); var reconnect = Reconnect(closeStatus, description); var disconnectTask = InvokeEventAsync(Disconnect, reconnect); if (reconnect) @@ -92,8 +126,6 @@ private async void HandleDisconnected(WebSocketCloseStatus? closeStatus, string? private async void HandleClosed() { - Interlocked.Exchange(ref _disconnectedTokenProvider, null)?.Cancel(); - InvokeLog(LogMessage.Info("Closed")); var closeTask = InvokeEventAsync(Close).ConfigureAwait(false); @@ -138,9 +170,13 @@ private protected Task StartAsync(CancellationToken cancellationToken = default) return ConnectAsync(cancellationToken); } - private protected Task ConnectAsync(CancellationToken cancellationToken = default) + private protected async Task ConnectAsync(CancellationToken cancellationToken = default) { - return _webSocket.ConnectAsync(Uri, cancellationToken); + HandleConnecting(); + var connection = await _connectionProvider.CreateWebSocketConnectionAsync(Uri, cancellationToken).ConfigureAwait(false); + var state = _state = new(connection); + HandleConnected(); + state.StartReading(ReadAsync); } /// @@ -152,17 +188,82 @@ private protected Task ConnectAsync(CancellationToken cancellationToken = defaul /// public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, string? statusDescription = null, CancellationToken cancellationToken = default) { - var closedTokenProvider = Interlocked.Exchange(ref _closedTokenProvider, null) ?? throw new InvalidOperationException("Connection not started."); + //var closedTokenProvider = Interlocked.Exchange(ref _closedTokenProvider, null) ?? throw new InvalidOperationException("Connection not started."); + + //closedTokenProvider.Cancel(); + + var state = Interlocked.Exchange(ref _state, null); + + if (state is null || !state.TryIndicateDisconnecting()) + throw new InvalidOperationException("Connection not started."); + + var connection = state.Connection; + + try + { + await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); + } + catch + { + connection.Abort(); + HandleClosed(); + throw; + } - closedTokenProvider.Cancel(); + await state.ReadTask.ConfigureAwait(false); + HandleClosed(); + } + + private async Task ReadAsync(State state) + { + var connection = state.Connection; + var token = state.DisconnectedTokenProvider.Token; try { - await _webSocket.CloseAsync(status, statusDescription, cancellationToken).ConfigureAwait(false); + using RentedArrayBufferWriter writer = new(DefaultBufferSize); + while (true) + { + var result = await connection.ReceiveAsync(writer.GetMemory(), token).ConfigureAwait(false); + + if (result.EndOfMessage) + { + if (result.MessageType is WebSocketMessageType.Close) + break; + + writer.Advance(result.Count); + HandleMessageReceived(writer.WrittenMemory); + writer.Clear(); + } + else + writer.Advance(result.Count); + } } catch { } + + if (state.TryIndicateDisconnecting()) + { + _state = null; + state.Dispose(); + HandleDisconnected((WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); + } + } + + public void Abort() + { + var state = Interlocked.Exchange(ref _state, null); + + if (state is null) + return; + + var disconnecting = state.TryIndicateDisconnecting(); + + state.Connection.Abort(); + + if (disconnecting) + HandleClosed(); } private protected virtual void OnConnected() @@ -171,9 +272,14 @@ private protected virtual void OnConnected() private protected ValueTask AbortAndReconnectAsync() { + var state = Interlocked.Exchange(ref _state, null); + + if (state is null || !state.TryIndicateDisconnecting()) + return default; + try { - _webSocket.Abort(); + state.Connection.Abort(); } catch (Exception ex) { @@ -183,8 +289,57 @@ private protected ValueTask AbortAndReconnectAsync() return ReconnectAsync(); } - public ValueTask SendPayloadAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - => _webSocket.SendAsync(buffer, cancellationToken); + public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) + { + properties ??= _defaultPayloadProperties; + while (true) + { + var state = _state; + + if (state is null) + { + if (_closedTokenProvider is null) + throw new InvalidOperationException("Connection not started."); + + if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + { + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // + continue; + } + + throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting."); + } + + var result = await _rateLimiter.TryAcquireAsync().ConfigureAwait(false); + + if (result.RateLimited) + { + if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryRateLimit)) + { + await Task.Delay(result.ResetAfter, cancellationToken).ConfigureAwait(false); + continue; + } + + throw new InvalidOperationException("Rate limit triggered."); + } + + try + { + await state.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not ArgumentException) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + continue; + + throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting.", ex); + } + + return; + } + } private protected abstract bool Reconnect(WebSocketCloseStatus? status, string? description); @@ -231,7 +386,7 @@ private protected async ValueTask ReconnectAsync() private protected async void StartHeartbeating(double interval) { - if (_disconnectedTokenProvider is not { Token: var cancellationToken }) + if (_state is not { DisconnectedTokenProvider.Token: var cancellationToken }) return; PeriodicTimer timer; @@ -253,6 +408,7 @@ private protected async void StartHeartbeating(double interval) try { await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false); + Console.WriteLine("Sending heartbeat"); await HeartbeatAsync(cancellationToken).ConfigureAwait(false); } catch @@ -517,8 +673,8 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - _webSocket.Dispose(); - _disconnectedTokenProvider?.Dispose(); + _state?.Dispose(); + _rateLimiter.Dispose(); _closedTokenProvider?.Dispose(); } } diff --git a/NetCord/Gateway/WebSocketPayloadProperties.cs b/NetCord/Gateway/WebSocketPayloadProperties.cs new file mode 100644 index 000000000..56e06a03b --- /dev/null +++ b/NetCord/Gateway/WebSocketPayloadProperties.cs @@ -0,0 +1,10 @@ +using NetCord.Gateway.WebSockets; + +namespace NetCord.Gateway; + +public partial record WebSocketPayloadProperties +{ + public WebSocketMessageType MessageType { get; set; } + public WebSocketMessageFlags MessageFlags { get; set; } = WebSocketMessageFlags.EndOfMessage; + public WebSocketRetryHandling RetryHandling { get; set; } = WebSocketRetryHandling.Retry; +} diff --git a/NetCord/Gateway/WebSocketRetryHandling.cs b/NetCord/Gateway/WebSocketRetryHandling.cs new file mode 100644 index 000000000..283993e55 --- /dev/null +++ b/NetCord/Gateway/WebSocketRetryHandling.cs @@ -0,0 +1,10 @@ +namespace NetCord.Gateway; + +[Flags] +public enum WebSocketRetryHandling : byte +{ + NoRetry = 0, + RetryRateLimit = 1 << 0, + RetryReconnect = 1 << 1, + Retry = RetryRateLimit | RetryReconnect, +} diff --git a/NetCord/Gateway/WebSockets/IWebSocket.cs b/NetCord/Gateway/WebSockets/IWebSocket.cs deleted file mode 100644 index 91bb1b9c5..000000000 --- a/NetCord/Gateway/WebSockets/IWebSocket.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Net.WebSockets; - -namespace NetCord.Gateway.WebSockets; - -public interface IWebSocket : IDisposable -{ - public event Action? Connecting; - public event Action? Connected; - public event Action? Disconnected; - public event Action? Closed; - public event Action>? MessageReceived; - - /// - /// Connects to a WebSocket server. - /// - public Task ConnectAsync(Uri uri, CancellationToken cancellationToken = default); - - /// - /// Closes the . - /// - public Task CloseAsync(WebSocketCloseStatus status, string? statusDescription, CancellationToken cancellationToken = default); - - /// - /// Aborts the . - /// - public void Abort(); - - /// - /// Sends a message. - /// - public ValueTask SendAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); -} diff --git a/NetCord/Gateway/WebSockets/IWebSocketConnection.cs b/NetCord/Gateway/WebSockets/IWebSocketConnection.cs new file mode 100644 index 000000000..bc45fb6f5 --- /dev/null +++ b/NetCord/Gateway/WebSockets/IWebSocketConnection.cs @@ -0,0 +1,16 @@ +namespace NetCord.Gateway.WebSockets; + +public interface IWebSocketConnection : IDisposable +{ + public int? CloseStatus { get; } + + public string? CloseStatusDescription { get; } + + public ValueTask SendAsync(ReadOnlyMemory buffer, WebSocketMessageType messageType, WebSocketMessageFlags messageFlags, CancellationToken cancellationToken = default); + + public ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default); + + public ValueTask CloseAsync(int closeStatus, string? closeStatusDescription, CancellationToken cancellationToken = default); + + public void Abort(); +} diff --git a/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs b/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs new file mode 100644 index 000000000..05a7eb8da --- /dev/null +++ b/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs @@ -0,0 +1,6 @@ +namespace NetCord.Gateway.WebSockets; + +public interface IWebSocketConnectionProvider +{ + public ValueTask CreateWebSocketConnectionAsync(Uri uri, CancellationToken cancellationToken = default); +} diff --git a/NetCord/Gateway/WebSockets/WebSocket.cs b/NetCord/Gateway/WebSockets/WebSocket.cs deleted file mode 100644 index b03e72691..000000000 --- a/NetCord/Gateway/WebSockets/WebSocket.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.WebSockets; - -namespace NetCord.Gateway.WebSockets; - -public sealed class WebSocket : IWebSocket -{ - private const int DefaultBufferSize = 8192; - - private State? _state; - private bool _disposed; - - public event Action? Connecting; - public event Action? Connected; - public event Action? Disconnected; - public event Action? Closed; - public event Action>? MessageReceived; - - public async Task ConnectAsync(Uri uri, CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(_disposed, typeof(WebSocket)); - - State newState = new(); - var state = Interlocked.CompareExchange(ref _state, newState, null); - if (state is not null) - { - newState.Dispose(); - ThrowAlreadyConnectingOrConnected(); - } - - InvokeEvent(Connecting); - - try - { - await newState.WebSocket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); - } - catch - { - Interlocked.Exchange(ref _state, null)?.Dispose(); - throw; - } - - InvokeEvent(Connected); - - newState.StartReading(ReadAsync); - } - - public async Task CloseAsync(WebSocketCloseStatus status, string? statusDescription, CancellationToken cancellationToken = default) - { - var state = Interlocked.Exchange(ref _state, null); - - if (state is null || !state.TryIndicateDisconnecting()) - ThrowNotConnected(); - - var webSocket = state.WebSocket; - - try - { - await webSocket.CloseOutputAsync(status, statusDescription, cancellationToken).ConfigureAwait(false); - } - catch - { - webSocket.Abort(); - InvokeEvent(Closed); - throw; - } - - await state.ReadTask.ConfigureAwait(false); - - InvokeEvent(Closed); - } - - public void Abort() - { - var state = Interlocked.Exchange(ref _state, null); - - if (state is null) - return; - - var disconnecting = state.TryIndicateDisconnecting(); - - state.WebSocket.Abort(); - - if (disconnecting) - InvokeEvent(Closed); - } - - public ValueTask SendAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - var state = _state; - - if (state is null) - ThrowNotConnected(); - - return state.WebSocket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); - } - - private async Task ReadAsync(State state) - { - var webSocket = state.WebSocket; - try - { - using RentedArrayBufferWriter writer = new(DefaultBufferSize); - while (true) - { - var result = await webSocket.ReceiveAsync(writer.GetMemory(), default).ConfigureAwait(false); - - if (result.EndOfMessage) - { - if (result.MessageType is WebSocketMessageType.Close) - break; - - writer.Advance(result.Count); - InvokeEvent(MessageReceived, writer.WrittenMemory); - writer.Clear(); - } - else - writer.Advance(result.Count); - } - } - catch - { - } - - if (state.TryIndicateDisconnecting()) - { - _state = null; - state.Dispose(); - InvokeEvent(Disconnected, webSocket.CloseStatus, webSocket.CloseStatusDescription); - } - } - - private static void InvokeEvent(Action? action) - { - if (action is not null) - { - try - { - action(); - } - catch - { - } - } - } - - private static void InvokeEvent(Action? action, WebSocketCloseStatus? status, string? description) - { - if (action is not null) - { - try - { - action(status, description); - } - catch - { - } - } - } - - private static void InvokeEvent(Action>? action, ReadOnlyMemory buffer) - { - if (action is not null) - { - try - { - action(buffer); - } - catch - { - } - } - } - - public void Dispose() - { - _state?.Dispose(); - _disposed = true; - } - - [DoesNotReturn] - private static void ThrowAlreadyConnectingOrConnected() - { - throw new InvalidOperationException("The WebSocket is already connecting or connected."); - } - - [DoesNotReturn] - private static void ThrowNotConnected() - { - throw new InvalidOperationException("The WebSocket is not connected."); - } - - private sealed class State : IDisposable - { - public ClientWebSocket WebSocket { get; } = new(); - - public Task ReadTask => _readCompletionSource.Task; - - public TaskCompletionSource _readCompletionSource = new(); - - private int _state; - - public async void StartReading(Func readAsync) - { - await readAsync(this).ConfigureAwait(false); - - _readCompletionSource.TrySetResult(); - } - - public bool TryIndicateDisconnecting() - { - return Interlocked.Exchange(ref _state, 1) is 0; - } - - public void Dispose() - { - WebSocket.Dispose(); - } - } -} diff --git a/NetCord/Gateway/WebSockets/WebSocketConnection.cs b/NetCord/Gateway/WebSockets/WebSocketConnection.cs new file mode 100644 index 000000000..4dfffcc0c --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketConnection.cs @@ -0,0 +1,50 @@ +using System.Net.WebSockets; + +namespace NetCord.Gateway.WebSockets; + +internal sealed class WebSocketConnection : IWebSocketConnection +{ + private readonly ClientWebSocket _webSocket; + + public static async ValueTask CreateAsync(Uri uri, CancellationToken cancellationToken = default) + { + ClientWebSocket webSocket = new(); + await webSocket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); + return new WebSocketConnection(webSocket); + } + + private WebSocketConnection(ClientWebSocket webSocket) + { + _webSocket = webSocket; + } + + public int? CloseStatus => (int?)_webSocket.CloseStatus; + + public string? CloseStatusDescription => _webSocket.CloseStatusDescription; + + public void Abort() + { + _webSocket.Abort(); + } + + public ValueTask CloseAsync(int closeStatus, string? closeStatusDescription, CancellationToken cancellationToken = default) + { + return new(_webSocket.CloseOutputAsync((WebSocketCloseStatus)closeStatus, closeStatusDescription, cancellationToken)); + } + + public async ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var result = await _webSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + return new(result.Count, (WebSocketMessageType)result.MessageType, result.EndOfMessage); + } + + public ValueTask SendAsync(ReadOnlyMemory buffer, WebSocketMessageType messageType, WebSocketMessageFlags messageFlags, CancellationToken cancellationToken = default) + { + return _webSocket.SendAsync(buffer, (System.Net.WebSockets.WebSocketMessageType)messageType, (System.Net.WebSockets.WebSocketMessageFlags)messageFlags, cancellationToken); + } + + public void Dispose() + { + _webSocket.Dispose(); + } +} diff --git a/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs b/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs new file mode 100644 index 000000000..4a9a12ae1 --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs @@ -0,0 +1,9 @@ +namespace NetCord.Gateway.WebSockets; + +public class WebSocketConnectionProvider : IWebSocketConnectionProvider +{ + public ValueTask CreateWebSocketConnectionAsync(Uri uri, CancellationToken cancellationToken = default) + { + return WebSocketConnection.CreateAsync(uri, cancellationToken); + } +} diff --git a/NetCord/Gateway/WebSockets/WebSocketConnectionReceiveResult.cs b/NetCord/Gateway/WebSockets/WebSocketConnectionReceiveResult.cs new file mode 100644 index 000000000..cc097e235 --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketConnectionReceiveResult.cs @@ -0,0 +1,27 @@ +namespace NetCord.Gateway.WebSockets; + +#pragma warning disable IDE0032 // Use auto property + +// Adopted from System.Net.WebSockets.ValueWebSocketReceiveResult + +public readonly struct WebSocketConnectionReceiveResult +{ + private readonly uint _countAndEndOfMessage; + private readonly WebSocketMessageType _messageType; + + public WebSocketConnectionReceiveResult(int count, WebSocketMessageType messageType, bool endOfMessage) + { + ArgumentOutOfRangeException.ThrowIfNegative(count, nameof(count)); + if ((uint)messageType > (uint)WebSocketMessageType.Close) + ThrowMessageTypeOutOfRange(); + + _countAndEndOfMessage = (uint)count | (uint)(endOfMessage ? 1 << 31 : 0); + _messageType = messageType; + + static void ThrowMessageTypeOutOfRange() => throw new ArgumentOutOfRangeException(nameof(messageType)); + } + + public int Count => (int)(_countAndEndOfMessage & 0x7FFFFFFF); + public bool EndOfMessage => (_countAndEndOfMessage & 0x80000000) == 0x80000000; + public WebSocketMessageType MessageType => _messageType; +} diff --git a/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs new file mode 100644 index 000000000..eb00d1303 --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs @@ -0,0 +1,8 @@ +namespace NetCord.Gateway.WebSockets; + +public enum WebSocketMessageFlags : byte +{ + None = 0, + EndOfMessage = 1, + DisableCompression = 2, +} diff --git a/NetCord/Gateway/WebSockets/WebSocketMessageType.cs b/NetCord/Gateway/WebSockets/WebSocketMessageType.cs new file mode 100644 index 000000000..890c77e6b --- /dev/null +++ b/NetCord/Gateway/WebSockets/WebSocketMessageType.cs @@ -0,0 +1,8 @@ +namespace NetCord.Gateway.WebSockets; + +public enum WebSocketMessageType : byte +{ + Text = 0, + Binary = 1, + Close = 2, +} diff --git a/NetCord/Rest/RateLimitedException.cs b/NetCord/Rest/RateLimitedException.cs index bf7719a99..540862c70 100644 --- a/NetCord/Rest/RateLimitedException.cs +++ b/NetCord/Rest/RateLimitedException.cs @@ -1,4 +1,4 @@ -namespace NetCord.Rest.RateLimits; +namespace NetCord.Rest; public class RateLimitedException(long reset, RateLimitScope scope) : Exception("Rate limit triggered.") { diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index b91155d80..d8930f907 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using NetCord.Gateway; +using NetCord.Gateway.Compression; using NetCord.JsonModels; using NetCord.Rest; using NetCord.Services; @@ -20,6 +21,7 @@ internal static class Program { Intents = GatewayIntents.All, ConnectionProperties = ConnectionPropertiesProperties.IOS, + Compression = new ZLibGatewayCompression(), }); private static readonly CommandService _commandService = new(); @@ -89,15 +91,25 @@ private static async Task Main() await _client.StartAsync(); await _client.ReadyAsync; - try + //try + //{ + // await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); + //} + //catch (RestException ex) + //{ + // var error = ex.Error; + // Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); + //} + + for (int i = 0; i < 120; i++) { - await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); - } - catch (RestException ex) - { - var error = ex.Error; - Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); + await _client.UpdatePresenceAsync(new(UserStatusType.Online) + { + Activities = [new($"wzium {i}", UserActivityType.Game)], + }); + Console.WriteLine(i); } + await Task.Delay(-1); } From 7396c41a49489280be5a9fd0dbc2d25bd0d20497 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Sun, 25 Aug 2024 22:23:50 +0200 Subject: [PATCH 06/33] Implement more --- NetCord/Gateway/GatewayClient.cs | 38 +- NetCord/Gateway/GatewayClientConfiguration.cs | 4 +- NetCord/Gateway/GatewayRateLimiter.cs | 45 -- NetCord/Gateway/GatewayRateLimiterProvider.cs | 41 ++ NetCord/Gateway/IRateLimiter.cs | 2 - NetCord/Gateway/IRateLimiterProvider.cs | 6 + .../Gateway/IWebSocketClientConfiguration.cs | 2 +- NetCord/Gateway/NullRateLimiter.cs | 6 +- NetCord/Gateway/NullRateLimiterProvider.cs | 8 + NetCord/Gateway/RateLimitAcquisitionResult.cs | 2 +- NetCord/Gateway/ShardedGatewayClient.cs | 2 +- .../ShardedGatewayClientConfiguration.cs | 2 +- NetCord/Gateway/Voice/VoiceClient.cs | 19 +- .../Gateway/Voice/VoiceClientConfiguration.cs | 2 +- NetCord/Gateway/WebSocketClient.cs | 401 ++++++++++++++---- .../WebSockets/IWebSocketConnection.cs | 2 + .../IWebSocketConnectionProvider.cs | 2 +- .../Gateway/WebSockets/WebSocketConnection.cs | 19 +- .../WebSockets/WebSocketConnectionProvider.cs | 4 +- .../WebSockets/WebSocketMessageFlags.cs | 6 +- NetCord/Rest/RateLimits/GlobalRateLimiter.cs | 2 +- .../RateLimits/NoRateLimitRouteRateLimiter.cs | 2 +- .../RateLimits/RateLimitAcquisitionResult.cs | 4 +- NetCord/Rest/RateLimits/RouteRateLimiter.cs | 2 +- .../RateLimits/UnknownRouteRateLimiter.cs | 4 +- Tests/NetCord.Test/Program.cs | 25 +- 26 files changed, 443 insertions(+), 209 deletions(-) delete mode 100644 NetCord/Gateway/GatewayRateLimiter.cs create mode 100644 NetCord/Gateway/GatewayRateLimiterProvider.cs create mode 100644 NetCord/Gateway/IRateLimiterProvider.cs create mode 100644 NetCord/Gateway/NullRateLimiterProvider.cs diff --git a/NetCord/Gateway/GatewayClient.cs b/NetCord/Gateway/GatewayClient.cs index 2e54d278e..77b379fb2 100644 --- a/NetCord/Gateway/GatewayClient.cs +++ b/NetCord/Gateway/GatewayClient.cs @@ -836,7 +836,7 @@ private protected override void OnConnected() _compression.Initialize(); } - private ValueTask SendIdentifyAsync(PresenceProperties? presence = null, CancellationToken cancellationToken = default) + private ValueTask SendIdentifyAsync(ConnectionState connectionState, PresenceProperties? presence = null, CancellationToken cancellationToken = default) { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Identify, new(Token.RawToken) { @@ -847,7 +847,7 @@ private ValueTask SendIdentifyAsync(PresenceProperties? presence = null, Cancell Intents = _configuration.Intents, }).Serialize(Serialization.Default.GatewayPayloadPropertiesGatewayIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } /// @@ -858,8 +858,8 @@ private ValueTask SendIdentifyAsync(PresenceProperties? presence = null, Cancell /// public async Task StartAsync(PresenceProperties? presence = null, CancellationToken cancellationToken = default) { - await StartAsync(cancellationToken).ConfigureAwait(false); - await SendIdentifyAsync(presence, cancellationToken).ConfigureAwait(false); + var connectionState = await StartAsync(cancellationToken).ConfigureAwait(false); + await SendIdentifyAsync(connectionState, presence, cancellationToken).ConfigureAwait(false); } /// @@ -871,35 +871,35 @@ public async Task StartAsync(PresenceProperties? presence = null, CancellationTo /// public async Task ResumeAsync(string sessionId, int sequenceNumber, CancellationToken cancellationToken = default) { - await ConnectAsync(cancellationToken).ConfigureAwait(false); - await TryResumeAsync(SessionId = sessionId, SequenceNumber = sequenceNumber, cancellationToken).ConfigureAwait(false); + var connectionState = await StartAsync(cancellationToken).ConfigureAwait(false); + await TryResumeAsync(connectionState, SessionId = sessionId, SequenceNumber = sequenceNumber, cancellationToken).ConfigureAwait(false); } private protected override bool Reconnect(WebSocketCloseStatus? status, string? description) => status is not ((WebSocketCloseStatus)4004 or (WebSocketCloseStatus)4010 or (WebSocketCloseStatus)4011 or (WebSocketCloseStatus)4012 or (WebSocketCloseStatus)4013 or (WebSocketCloseStatus)4014); - private protected override ValueTask TryResumeAsync(CancellationToken cancellationToken = default) + private protected override ValueTask TryResumeAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) { - return TryResumeAsync(SessionId!, SequenceNumber, cancellationToken); + return TryResumeAsync(connectionState, SessionId!, SequenceNumber, cancellationToken); } - private ValueTask TryResumeAsync(string sessionId, int sequenceNumber, CancellationToken cancellationToken = default) + private ValueTask TryResumeAsync(ConnectionState connectionState, string sessionId, int sequenceNumber, CancellationToken cancellationToken = default) { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Resume, new(Token.RawToken, sessionId, sequenceNumber)).Serialize(Serialization.Default.GatewayPayloadPropertiesGatewayResumeProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } - private protected override ValueTask HeartbeatAsync(CancellationToken cancellationToken = default) + private protected override ValueTask HeartbeatAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) { var serializedPayload = new GatewayPayloadProperties(GatewayOpcode.Heartbeat, SequenceNumber).Serialize(Serialization.Default.GatewayPayloadPropertiesInt32); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } private protected override JsonPayload CreatePayload(ReadOnlyMemory payload) => JsonSerializer.Deserialize(_compression.Decompress(payload).Span, Serialization.Default.JsonPayload)!; - private protected override async Task ProcessPayloadAsync(JsonPayload payload) + private protected override async Task ProcessPayloadAsync(State state, JsonPayload payload) { switch ((GatewayOpcode)payload.Opcode) { @@ -907,7 +907,7 @@ private protected override async Task ProcessPayloadAsync(JsonPayload payload) SequenceNumber = payload.SequenceNumber.GetValueOrDefault(); try { - await ProcessEventAsync(payload).ConfigureAwait(false); + await ProcessEventAsync(state, payload).ConfigureAwait(false); } catch (Exception ex) { @@ -918,13 +918,13 @@ private protected override async Task ProcessPayloadAsync(JsonPayload payload) break; case GatewayOpcode.Reconnect: InvokeLog(LogMessage.Info("Reconnect request")); - await AbortAndReconnectAsync().ConfigureAwait(false); + await AbortAndReconnectAsync(state).ConfigureAwait(false); break; case GatewayOpcode.InvalidSession: InvokeLog(LogMessage.Info("Invalid session")); try { - await SendIdentifyAsync().ConfigureAwait(false); + await SendIdentifyAsync(state.ConnectionState!).ConfigureAwait(false); } catch (Exception ex) { @@ -932,7 +932,7 @@ private protected override async Task ProcessPayloadAsync(JsonPayload payload) } break; case GatewayOpcode.Hello: - StartHeartbeating(payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); + StartHeartbeating(state.ConnectionState!, payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); break; case GatewayOpcode.HeartbeatACK: await UpdateLatencyAsync(_latencyTimer.Elapsed).ConfigureAwait(false); @@ -970,7 +970,7 @@ public ValueTask RequestGuildUsersAsync(GuildUsersRequestProperties requestPrope return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesGuildUsersRequestProperties), properties, cancellationToken); } - private async Task ProcessEventAsync(JsonPayload payload) + private async Task ProcessEventAsync(State state, JsonPayload payload) { var data = payload.Data.GetValueOrDefault(); var name = payload.Event!; @@ -992,6 +992,7 @@ await InvokeEventAsync(Ready, args, data => SessionId = args.SessionId; ApplicationFlags = args.ApplicationFlags; + state.IndicateReady(state.ConnectionState!); _readyCompletionSource.TrySetResult(); }).ConfigureAwait(false); await updateLatencyTask.ConfigureAwait(false); @@ -1004,6 +1005,7 @@ await InvokeEventAsync(Ready, args, data => var updateLatencyTask = UpdateLatencyAsync(latency); var resumeTask = InvokeResumeEventAsync(); + state.IndicateReady(state.ConnectionState!); _readyCompletionSource.TrySetResult(); await updateLatencyTask.ConfigureAwait(false); diff --git a/NetCord/Gateway/GatewayClientConfiguration.cs b/NetCord/Gateway/GatewayClientConfiguration.cs index 7edba9551..b9535e00c 100644 --- a/NetCord/Gateway/GatewayClientConfiguration.cs +++ b/NetCord/Gateway/GatewayClientConfiguration.cs @@ -8,7 +8,7 @@ namespace NetCord.Gateway; public class GatewayClientConfiguration : IWebSocketClientConfiguration { public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; init; } - public IRateLimiter? RateLimiter { get; init; } + public IRateLimiterProvider? RateLimiterProvider { get; init; } public WebSocketPayloadProperties? DefaultPayloadProperties { get; init; } public IReconnectStrategy? ReconnectStrategy { get; init; } public ILatencyTimer? LatencyTimer { get; init; } @@ -24,5 +24,5 @@ public class GatewayClientConfiguration : IWebSocketClientConfiguration public bool CacheDMChannels { get; init; } = true; public Rest.RestClientConfiguration? RestClientConfiguration { get; init; } - IRateLimiter? IWebSocketClientConfiguration.RateLimiter => RateLimiter is { } rateLimiter ? rateLimiter : new GatewayRateLimiter(120, 60_000); + IRateLimiterProvider? IWebSocketClientConfiguration.RateLimiterProvider => RateLimiterProvider is { } rateLimiter ? rateLimiter : new GatewayRateLimiterProvider(120, 60_000); } diff --git a/NetCord/Gateway/GatewayRateLimiter.cs b/NetCord/Gateway/GatewayRateLimiter.cs deleted file mode 100644 index 433ae1bee..000000000 --- a/NetCord/Gateway/GatewayRateLimiter.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace NetCord.Gateway; - -public sealed class GatewayRateLimiter(int limit, long duration) : IRateLimiter -{ - private readonly object _lock = new(); - private readonly int _limit = limit; - private int _remaining = limit; - private long _reset; - - public ValueTask TryAcquireAsync() - { - var timestamp = Environment.TickCount64; - lock (_lock) - { - var diff = _reset - timestamp; - if (diff <= 0) - { - _remaining = _limit - 1; - _reset = timestamp + duration; - } - else - { - if (_remaining == 0) - return new(RateLimitAcquisitionResult.RateLimit((int)diff)); - else - _remaining--; - } - } - - return new(RateLimitAcquisitionResult.NoRateLimit()); - } - - public void Reset() - { - lock (_lock) - { - _remaining = _limit; - _reset = 0; - } - } - - public void Dispose() - { - } -} diff --git a/NetCord/Gateway/GatewayRateLimiterProvider.cs b/NetCord/Gateway/GatewayRateLimiterProvider.cs new file mode 100644 index 000000000..6463b566e --- /dev/null +++ b/NetCord/Gateway/GatewayRateLimiterProvider.cs @@ -0,0 +1,41 @@ +namespace NetCord.Gateway; + +public class GatewayRateLimiterProvider(int limit, long duration) : IRateLimiterProvider +{ + public IRateLimiter CreateRateLimiter() => new GatewayRateLimiter(limit, duration); + + private sealed class GatewayRateLimiter(int limit, long duration) : IRateLimiter + { + private readonly object _lock = new(); + private readonly int _limit = limit; + private int _remaining = limit; + private long _reset; + + public ValueTask TryAcquireAsync() + { + var timestamp = Environment.TickCount64; + lock (_lock) + { + var diff = _reset - timestamp; + if (diff <= 0) + { + _remaining = _limit - 1; + _reset = timestamp + duration; + } + else + { + if (_remaining == 0) + return new(RateLimitAcquisitionResult.RateLimit((int)diff)); + else + _remaining--; + } + } + + return new(RateLimitAcquisitionResult.NoRateLimit); + } + + public void Dispose() + { + } + } +} diff --git a/NetCord/Gateway/IRateLimiter.cs b/NetCord/Gateway/IRateLimiter.cs index c7a25ace4..7dd7d0ce4 100644 --- a/NetCord/Gateway/IRateLimiter.cs +++ b/NetCord/Gateway/IRateLimiter.cs @@ -3,6 +3,4 @@ public interface IRateLimiter : IDisposable { public ValueTask TryAcquireAsync(); - - public void Reset(); } diff --git a/NetCord/Gateway/IRateLimiterProvider.cs b/NetCord/Gateway/IRateLimiterProvider.cs new file mode 100644 index 000000000..885da5e78 --- /dev/null +++ b/NetCord/Gateway/IRateLimiterProvider.cs @@ -0,0 +1,6 @@ +namespace NetCord.Gateway; + +public interface IRateLimiterProvider +{ + public IRateLimiter CreateRateLimiter(); +} diff --git a/NetCord/Gateway/IWebSocketClientConfiguration.cs b/NetCord/Gateway/IWebSocketClientConfiguration.cs index 131f763e3..392912f0a 100644 --- a/NetCord/Gateway/IWebSocketClientConfiguration.cs +++ b/NetCord/Gateway/IWebSocketClientConfiguration.cs @@ -9,6 +9,6 @@ internal interface IWebSocketClientConfiguration public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; } public IReconnectStrategy? ReconnectStrategy { get; } public ILatencyTimer? LatencyTimer { get; } - public IRateLimiter? RateLimiter { get; } + public IRateLimiterProvider? RateLimiterProvider { get; } public WebSocketPayloadProperties? DefaultPayloadProperties { get; } } diff --git a/NetCord/Gateway/NullRateLimiter.cs b/NetCord/Gateway/NullRateLimiter.cs index f03ad4929..5778f2b6b 100644 --- a/NetCord/Gateway/NullRateLimiter.cs +++ b/NetCord/Gateway/NullRateLimiter.cs @@ -8,11 +8,7 @@ private NullRateLimiter() { } - public ValueTask TryAcquireAsync() => new(RateLimitAcquisitionResult.NoRateLimit()); - - public void Reset() - { - } + public ValueTask TryAcquireAsync() => new(RateLimitAcquisitionResult.NoRateLimit); public void Dispose() { diff --git a/NetCord/Gateway/NullRateLimiterProvider.cs b/NetCord/Gateway/NullRateLimiterProvider.cs new file mode 100644 index 000000000..2a545e56c --- /dev/null +++ b/NetCord/Gateway/NullRateLimiterProvider.cs @@ -0,0 +1,8 @@ +namespace NetCord.Gateway; + +internal class NullRateLimiterProvider : IRateLimiterProvider +{ + public static NullRateLimiterProvider Instance { get; } = new(); + + public IRateLimiter CreateRateLimiter() => NullRateLimiter.Instance; +} diff --git a/NetCord/Gateway/RateLimitAcquisitionResult.cs b/NetCord/Gateway/RateLimitAcquisitionResult.cs index f3c97a851..d87e286ab 100644 --- a/NetCord/Gateway/RateLimitAcquisitionResult.cs +++ b/NetCord/Gateway/RateLimitAcquisitionResult.cs @@ -8,7 +8,7 @@ private RateLimitAcquisitionResult(int resetAfter, bool rateLimited) RateLimited = rateLimited; } - public static RateLimitAcquisitionResult NoRateLimit() => new(0, false); + public static RateLimitAcquisitionResult NoRateLimit { get; } = new(0, false); public static RateLimitAcquisitionResult RateLimit(int resetAfter) => new(resetAfter, true); diff --git a/NetCord/Gateway/ShardedGatewayClient.cs b/NetCord/Gateway/ShardedGatewayClient.cs index 5f1b11bc4..087c3244f 100644 --- a/NetCord/Gateway/ShardedGatewayClient.cs +++ b/NetCord/Gateway/ShardedGatewayClient.cs @@ -220,7 +220,7 @@ private GatewayClientConfiguration GetGatewayClientConfiguration(Shard shard) return new() { WebSocketConnectionProvider = configuration.WebSocketConnectionProviderFactory!(shard), - RateLimiter = configuration.RateLimiterFactory!(shard), + RateLimiterProvider = configuration.RateLimiterProviderFactory!(shard), DefaultPayloadProperties = configuration.DefaultPayloadPropertiesFactory!(shard), ReconnectStrategy = configuration.ReconnectStrategyFactory!(shard), LatencyTimer = configuration.LatencyTimerFactory!(shard), diff --git a/NetCord/Gateway/ShardedGatewayClientConfiguration.cs b/NetCord/Gateway/ShardedGatewayClientConfiguration.cs index 24718ee9c..d6a70c26c 100644 --- a/NetCord/Gateway/ShardedGatewayClientConfiguration.cs +++ b/NetCord/Gateway/ShardedGatewayClientConfiguration.cs @@ -8,7 +8,7 @@ namespace NetCord.Gateway; public class ShardedGatewayClientConfiguration { public Func? WebSocketConnectionProviderFactory { get; init; } - public Func? RateLimiterFactory { get; init; } + public Func? RateLimiterProviderFactory { get; init; } public Func? DefaultPayloadPropertiesFactory { get; init; } public Func? ReconnectStrategyFactory { get; init; } public Func? LatencyTimerFactory { get; init; } diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index c42cfb6e4..d4c9a760b 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -56,7 +56,7 @@ private ValueTask SendIdentifyAsync(CancellationToken cancellationToken = defaul { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Identify, new(GuildId, UserId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendPayloadAsync(serializedPayload, _internalPayloadProperties, cancellationToken); } /// @@ -75,28 +75,28 @@ private ValueTask SendIdentifyAsync(CancellationToken cancellationToken = defaul /// public async Task ResumeAsync(CancellationToken cancellationToken = default) { - await ConnectAsync(cancellationToken).ConfigureAwait(false); - await TryResumeAsync(cancellationToken).ConfigureAwait(false); + var connectionState = await base.StartAsync(cancellationToken).ConfigureAwait(false); + await TryResumeAsync(connectionState, cancellationToken).ConfigureAwait(false); } private protected override bool Reconnect(WebSocketCloseStatus? status, string? description) => status is not ((WebSocketCloseStatus)4004 or (WebSocketCloseStatus)4006 or (WebSocketCloseStatus)4009 or (WebSocketCloseStatus)4014); - private protected override ValueTask TryResumeAsync(CancellationToken cancellationToken = default) + private protected override ValueTask TryResumeAsync(ConnectionState state, CancellationToken cancellationToken = default) { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Resume, new(GuildId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceResumeProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(state, serializedPayload, _internalPayloadProperties, cancellationToken); } - private protected override ValueTask HeartbeatAsync(CancellationToken cancellationToken = default) + private protected override ValueTask HeartbeatAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Heartbeat, Environment.TickCount).Serialize(Serialization.Default.VoicePayloadPropertiesInt32); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, new() { RetryHandling = WebSocketRetryHandling.RetryRateLimit }, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } - private protected override async Task ProcessPayloadAsync(JsonPayload payload) + private protected override async Task ProcessPayloadAsync(State state, JsonPayload payload) { switch ((VoiceOpcode)payload.Opcode) { @@ -163,6 +163,7 @@ void GetIpAndPort(out string ip, out ushort port) InvokeLog(LogMessage.Info("Ready")); var readyTask = InvokeEventAsync(Ready); + state.IndicateReady(state.ConnectionState!); _readyCompletionSource.TrySetResult(); await readyTask.ConfigureAwait(false); @@ -189,7 +190,7 @@ void GetIpAndPort(out string ip, out ushort port) break; case VoiceOpcode.Hello: { - StartHeartbeating(payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); + StartHeartbeating(state.ConnectionState!, payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); } break; case VoiceOpcode.Resumed: diff --git a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs index 5d581f42b..8c1ff16ea 100644 --- a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs +++ b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs @@ -18,5 +18,5 @@ public class VoiceClientConfiguration : IWebSocketClientConfiguration public IVoiceEncryption? Encryption { get; init; } public bool RedirectInputStreams { get; init; } - IRateLimiter? IWebSocketClientConfiguration.RateLimiter => null; + IRateLimiterProvider? IWebSocketClientConfiguration.RateLimiterProvider => null; } diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 9cd1bebca..4d2e03dbb 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text.Json; using NetCord.Gateway.JsonModels; @@ -12,27 +13,30 @@ namespace NetCord.Gateway; public abstract class WebSocketClient : IDisposable { - private sealed class State(IWebSocketConnection connection) : IDisposable + private protected sealed class ConnectionState(IWebSocketConnection connection, IRateLimiter rateLimiter) : IDisposable { - public IWebSocketConnection Connection { get; } = connection; + public IWebSocketConnection Connection => connection; + + public IRateLimiter RateLimiter => rateLimiter; public CancellationTokenProvider DisconnectedTokenProvider { get; } = new(); public Task ReadTask => _readCompletionSource.Task; - public TaskCompletionSource _readCompletionSource = new(); - - public Task ReadyTask => _readCompletionSource.Task; - - public TaskCompletionSource _readyCompletionSource = new(); + private readonly TaskCompletionSource _readCompletionSource = new(); private int _state; - public async void StartReading(Func readAsync) + public async void StartReading(State state, Func readAsync) { - await readAsync(this).ConfigureAwait(false); - - _readCompletionSource.TrySetResult(); + try + { + await readAsync(state).ConfigureAwait(false); + } + finally + { + _readCompletionSource.TrySetResult(); + } } public bool TryIndicateDisconnecting() @@ -48,10 +52,94 @@ public bool TryIndicateDisconnecting() public void Dispose() { DisconnectedTokenProvider.Dispose(); + RateLimiter.Dispose(); Connection.Dispose(); } } + private protected sealed class State : IDisposable + { + private ConnectionState? _connectionState; + + public ConnectionState? ConnectionState => _connectionState; + + public CancellationTokenProvider ClosedTokenProvider { get; } = new(); + + public Task ReadyTask => _readyCompletionSource.Task; + + private TaskCompletionSource _readyCompletionSource = new(); + + public Task ConnectedTask => _connectedCompletionSource.Task; + + private TaskCompletionSource _connectedCompletionSource = new(); + + public void IndicateConnected(ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + if (_connectionState != connectionState) + return; + + _connectedCompletionSource.TrySetResult(connectionState); + } + } + + public void IndicateReady(ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + if (_connectionState != connectionState) + return; + + _readyCompletionSource.TrySetResult(connectionState); + } + } + + public bool TryIndicateConnecting(ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + var previousState = _connectionState; + if (previousState is not null) + return false; + + _connectionState = connectionState; + } + + return true; + } + + public bool TryIndicateDisconnecting([MaybeNullWhen(false)] out ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + var previousState = _connectionState; + if (previousState is null || !previousState.TryIndicateDisconnecting()) + { + connectionState = null; + return false; + } + + _connectionState = null; + connectionState = previousState; + + _readyCompletionSource.TrySetCanceled(); + _readyCompletionSource = new(); + + _connectedCompletionSource.TrySetCanceled(); + _connectedCompletionSource = new(); + } + + return true; + } + + public void Dispose() + { + _connectionState?.Dispose(); + ClosedTokenProvider.Dispose(); + } + } + private const int DefaultBufferSize = 8192; private protected WebSocketClient(IWebSocketClientConfiguration configuration) @@ -59,20 +147,25 @@ private protected WebSocketClient(IWebSocketClientConfiguration configuration) _connectionProvider = configuration.WebSocketConnectionProvider ?? new WebSocketConnectionProvider(); _reconnectStrategy = configuration.ReconnectStrategy ?? new ReconnectStrategy(); _latencyTimer = configuration.LatencyTimer ?? new LatencyTimer(); - _rateLimiter = configuration.RateLimiter ?? NullRateLimiter.Instance; + _rateLimiterProvider = configuration.RateLimiterProvider ?? NullRateLimiterProvider.Instance; _defaultPayloadProperties = configuration.DefaultPayloadProperties is { } defaultPayloadProperties ? defaultPayloadProperties with { } : new(); } + private protected static readonly WebSocketPayloadProperties _internalPayloadProperties = new() + { + MessageFlags = WebSocketMessageFlags.EndOfMessage | WebSocketMessageFlags.BypassReady, + RetryHandling = WebSocketRetryHandling.RetryRateLimit, + }; + private readonly object _eventsLock = new(); private readonly IWebSocketConnectionProvider _connectionProvider; private readonly IReconnectStrategy _reconnectStrategy; - private readonly IRateLimiter _rateLimiter; + private readonly IRateLimiterProvider _rateLimiterProvider; private readonly WebSocketPayloadProperties _defaultPayloadProperties; private protected readonly ILatencyTimer _latencyTimer; private protected readonly TaskCompletionSource _readyCompletionSource = new(); - private CancellationTokenProvider? _closedTokenProvider; private State? _state; private protected abstract Uri Uri { get; } @@ -104,22 +197,26 @@ private async void HandleConnecting() await InvokeEventAsync(Connecting).ConfigureAwait(false); } - private async void HandleConnected() + private async void HandleConnected(State state) { OnConnected(); + state.IndicateConnected(state.ConnectionState!); InvokeLog(LogMessage.Info("Connected")); await InvokeEventAsync(Connect).ConfigureAwait(false); } - private async void HandleDisconnected(WebSocketCloseStatus? closeStatus, string? description) + private async void HandleDisconnected(State state, WebSocketCloseStatus? closeStatus, string? description) { InvokeLog(LogMessage.Info("Disconnected", string.IsNullOrEmpty(description) ? null : (description.EndsWith('.') ? description[..^1] : description))); var reconnect = Reconnect(closeStatus, description); var disconnectTask = InvokeEventAsync(Disconnect, reconnect); if (reconnect) - await ReconnectAsync().ConfigureAwait(false); + await ReconnectAsync(state).ConfigureAwait(false); else + { + _state = null; _readyCompletionSource.TrySetCanceled(); + } await disconnectTask.ConfigureAwait(false); } @@ -134,7 +231,7 @@ private async void HandleClosed() await closeTask; } - private async void HandleMessageReceived(ReadOnlyMemory data) + private async void HandleMessageReceived(State state, ReadOnlyMemory data) { try { @@ -146,11 +243,11 @@ private async void HandleMessageReceived(ReadOnlyMemory data) catch (Exception ex) { InvokeLog(LogMessage.Error(ex)); - await AbortAndReconnectAsync().ConfigureAwait(false); + await AbortAndReconnectAsync(state).ConfigureAwait(false); return; } - await ProcessPayloadAsync(payload).ConfigureAwait(false); + await ProcessPayloadAsync(state, payload).ConfigureAwait(false); } catch (Exception ex) { @@ -158,25 +255,52 @@ private async void HandleMessageReceived(ReadOnlyMemory data) } } - private protected Task StartAsync(CancellationToken cancellationToken = default) + private protected Task StartAsync(CancellationToken cancellationToken = default) { - CancellationTokenProvider newTokenProvider = new(); - if (Interlocked.CompareExchange(ref _closedTokenProvider, newTokenProvider, null) is not null) + State state = new(); + if (Interlocked.CompareExchange(ref _state, state, null) is not null) { - newTokenProvider.Dispose(); - throw new InvalidOperationException("Connection already started."); + state.Dispose(); + ThrowConnectionAlreadyStarted(); } - return ConnectAsync(cancellationToken); + return ConnectAsync(state, cancellationToken); + + //CancellationTokenProvider newTokenProvider = new(); + //if (Interlocked.CompareExchange(ref _closedTokenProvider, newTokenProvider, null) is not null) + //{ + // newTokenProvider.Dispose(); + // throw new InvalidOperationException("Connection already started."); + //} + + //if (Interlocked.CompareExchange(ref _state, new(), null) is not null) + // throw new InvalidOperationException("Connection already started."); + + //return ConnectAsync(cancellationToken); } - private protected async Task ConnectAsync(CancellationToken cancellationToken = default) + private protected async Task ConnectAsync(State state, CancellationToken cancellationToken = default) { + var connection = _connectionProvider.CreateConnection(); + var rateLimiter = _rateLimiterProvider.CreateRateLimiter(); + ConnectionState connectionState = new(connection, rateLimiter); + if (!state.TryIndicateConnecting(connectionState)) + { + connectionState.Dispose(); + ThrowConnectionAlreadyStarted(); + } + HandleConnecting(); - var connection = await _connectionProvider.CreateWebSocketConnectionAsync(Uri, cancellationToken).ConfigureAwait(false); - var state = _state = new(connection); - HandleConnected(); - state.StartReading(ReadAsync); + await connection.OpenAsync(Uri, cancellationToken).ConfigureAwait(false); + HandleConnected(state); + connectionState.StartReading(state, ReadAsync); + return connectionState; + + //HandleConnecting(); + //var connection = await _connectionProvider.CreateWebSocketConnectionAsync(Uri, cancellationToken).ConfigureAwait(false); + //var state = _state = new(connection); + //HandleConnected(); + //state.StartReading(ReadAsync); } /// @@ -188,17 +312,15 @@ private protected async Task ConnectAsync(CancellationToken cancellationToken = /// public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, string? statusDescription = null, CancellationToken cancellationToken = default) { - //var closedTokenProvider = Interlocked.Exchange(ref _closedTokenProvider, null) ?? throw new InvalidOperationException("Connection not started."); - - //closedTokenProvider.Cancel(); - var state = Interlocked.Exchange(ref _state, null); - if (state is null || !state.TryIndicateDisconnecting()) - throw new InvalidOperationException("Connection not started."); + if (state is null) + ThrowConnectionNotStarted(); - var connection = state.Connection; + if (!state.TryIndicateDisconnecting(out var connectionState)) + return; + var connection = connectionState.Connection; try { await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); @@ -210,15 +332,16 @@ public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus. throw; } - await state.ReadTask.ConfigureAwait(false); + await connectionState.ReadTask.ConfigureAwait(false); HandleClosed(); } private async Task ReadAsync(State state) { - var connection = state.Connection; - var token = state.DisconnectedTokenProvider.Token; + var connectionState = state.ConnectionState!; + var connection = connectionState.Connection; + var token = connectionState.DisconnectedTokenProvider.Token; try { using RentedArrayBufferWriter writer = new(DefaultBufferSize); @@ -232,7 +355,7 @@ private async Task ReadAsync(State state) break; writer.Advance(result.Count); - HandleMessageReceived(writer.WrittenMemory); + HandleMessageReceived(state, writer.WrittenMemory); writer.Clear(); } else @@ -243,11 +366,10 @@ private async Task ReadAsync(State state) { } - if (state.TryIndicateDisconnecting()) + if (state.TryIndicateDisconnecting(out _)) { - _state = null; - state.Dispose(); - HandleDisconnected((WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); + connectionState.Dispose(); + HandleDisconnected(state, (WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); } } @@ -258,94 +380,180 @@ public void Abort() if (state is null) return; - var disconnecting = state.TryIndicateDisconnecting(); - - state.Connection.Abort(); - - if (disconnecting) + if (state.TryIndicateDisconnecting(out var connectionState)) + { + connectionState.Connection.Abort(); HandleClosed(); + } } private protected virtual void OnConnected() { } - private protected ValueTask AbortAndReconnectAsync() + private protected ValueTask AbortAndReconnectAsync(State state) { - var state = Interlocked.Exchange(ref _state, null); - - if (state is null || !state.TryIndicateDisconnecting()) + if (!state.TryIndicateDisconnecting(out var connectionState)) return default; try { - state.Connection.Abort(); + connectionState.Connection.Abort(); } catch (Exception ex) { InvokeLog(LogMessage.Error(ex)); } - return ReconnectAsync(); + return ReconnectAsync(state); } public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPayloadProperties? properties = null, CancellationToken cancellationToken = default) { properties ??= _defaultPayloadProperties; + while (true) { var state = _state; if (state is null) - { - if (_closedTokenProvider is null) - throw new InvalidOperationException("Connection not started."); + ThrowConnectionNotStarted(); + + var task = properties.MessageFlags.HasFlag(WebSocketMessageFlags.BypassReady) ? state.ConnectedTask : state.ReadyTask; + ConnectionState connectionState; + if (!task.IsCompleted) + { if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + connectionState = await task.ConfigureAwait(false); + else { - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // - continue; + ThrowConnectionNotStarted(); + return; } - - throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting."); } + else + connectionState = state.ConnectionState!; - var result = await _rateLimiter.TryAcquireAsync().ConfigureAwait(false); + var exception = await TrySendConnectionPayloadAsync(connectionState, buffer, properties, cancellationToken).ConfigureAwait(false); + + if (exception is null) + return; + + if (!properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + ThrowConnectionNotStarted(); + + //var rateLimiter = connectionState.RateLimiter; + + //if (state is null) + //{ + // //if (_closedTokenProvider is null) + // // throw new InvalidOperationException("Connection not started."); + + // //if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + // //{ + // // await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // + // // continue; + // //} + + // //throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting."); + //} + + //var result = await rateLimiter.TryAcquireAsync().ConfigureAwait(false); + + //if (result.RateLimited) + //{ + // if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryRateLimit)) + // { + // await Task.Delay(result.ResetAfter, cancellationToken).ConfigureAwait(false); + // continue; + // } + + // throw new InvalidOperationException("Rate limit triggered."); + //} + + //try + //{ + // await connectionState.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, cancellationToken).ConfigureAwait(false); + //} + //catch (Exception ex) when (ex is not ArgumentException) + //{ + // cancellationToken.ThrowIfCancellationRequested(); + + // if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + // continue; + + // throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting.", ex); + //} + + //return; + } + } + + private protected static async ValueTask SendConnectionPayloadAsync(ConnectionState connectionState, ReadOnlyMemory buffer, WebSocketPayloadProperties properties, CancellationToken cancellationToken = default) + { + var exception = await TrySendConnectionPayloadAsync(connectionState, buffer, properties, cancellationToken).ConfigureAwait(false); + if (exception is null) + return; + + ThrowConnectionNotStarted(exception); + } + + private protected static async ValueTask TrySendConnectionPayloadAsync(ConnectionState connectionState, ReadOnlyMemory buffer, WebSocketPayloadProperties properties, CancellationToken cancellationToken = default) + { + var rateLimiter = connectionState.RateLimiter; + + var disconnectedToken = connectionState.DisconnectedTokenProvider.Token; + + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(disconnectedToken, cancellationToken); + var linkedToken = linkedTokenSource.Token; + + while (true) + { + var result = await rateLimiter.TryAcquireAsync().ConfigureAwait(false); if (result.RateLimited) { if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryRateLimit)) { - await Task.Delay(result.ResetAfter, cancellationToken).ConfigureAwait(false); + try + { + await Task.Delay(result.ResetAfter, linkedToken).ConfigureAwait(false); + } + catch (TaskCanceledException ex) + { + if (disconnectedToken.IsCancellationRequested) + return ex; + + throw; + } + continue; } - throw new InvalidOperationException("Rate limit triggered."); + ThrowRateLimitTriggered(result.ResetAfter); } try { - await state.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, cancellationToken).ConfigureAwait(false); + await connectionState.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, linkedToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not ArgumentException) { cancellationToken.ThrowIfCancellationRequested(); - if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) - continue; - - throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting.", ex); + return ex; } - return; + return null; } } private protected abstract bool Reconnect(WebSocketCloseStatus? status, string? description); - private protected async ValueTask ReconnectAsync() + private protected async ValueTask ReconnectAsync(State state) { - if (_closedTokenProvider is not { Token: var cancellationToken }) + if (state is not { ClosedTokenProvider.Token: var cancellationToken }) return; foreach (var delay in _reconnectStrategy.GetDelays()) @@ -359,9 +567,10 @@ private protected async ValueTask ReconnectAsync() return; } + ConnectionState connectionState; try { - await ConnectAsync(cancellationToken).ConfigureAwait(false); + connectionState = await ConnectAsync(state, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -371,7 +580,7 @@ private protected async ValueTask ReconnectAsync() try { - await TryResumeAsync(cancellationToken).ConfigureAwait(false); + await TryResumeAsync(connectionState, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -382,12 +591,11 @@ private protected async ValueTask ReconnectAsync() } } - private protected abstract ValueTask TryResumeAsync(CancellationToken cancellationToken = default); + private protected abstract ValueTask TryResumeAsync(ConnectionState state, CancellationToken cancellationToken = default); - private protected async void StartHeartbeating(double interval) + private protected async void StartHeartbeating(ConnectionState state, double interval) { - if (_state is not { DisconnectedTokenProvider.Token: var cancellationToken }) - return; + var cancellationToken = state.DisconnectedTokenProvider.Token; PeriodicTimer timer; @@ -408,8 +616,7 @@ private protected async void StartHeartbeating(double interval) try { await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false); - Console.WriteLine("Sending heartbeat"); - await HeartbeatAsync(cancellationToken).ConfigureAwait(false); + await HeartbeatAsync(state, cancellationToken).ConfigureAwait(false); } catch { @@ -419,11 +626,11 @@ private protected async void StartHeartbeating(double interval) } } - private protected abstract ValueTask HeartbeatAsync(CancellationToken cancellationToken = default); + private protected abstract ValueTask HeartbeatAsync(ConnectionState connectionState, CancellationToken cancellationToken = default); private protected virtual JsonPayload CreatePayload(ReadOnlyMemory payload) => JsonSerializer.Deserialize(payload.Span, Serialization.Default.JsonPayload)!; - private protected abstract Task ProcessPayloadAsync(JsonPayload payload); + private protected abstract Task ProcessPayloadAsync(State state, JsonPayload payload); private protected async void InvokeLog(LogMessage logMessage) { @@ -663,6 +870,24 @@ private async ValueTask AwaitEventAsync(ValueTask task) } } + [DoesNotReturn] + private static void ThrowConnectionAlreadyStarted() + { + throw new InvalidOperationException("Connection already started."); + } + + [DoesNotReturn] + private static void ThrowConnectionNotStarted(Exception? innerException = null) + { + throw new InvalidOperationException("Connection not started.", innerException); + } + + [DoesNotReturn] + private static void ThrowRateLimitTriggered(int resetAfter) + { + throw new InvalidOperationException("Rate limit triggered."); + } + public void Dispose() { Dispose(true); @@ -672,10 +897,6 @@ public void Dispose() protected virtual void Dispose(bool disposing) { if (disposing) - { _state?.Dispose(); - _rateLimiter.Dispose(); - _closedTokenProvider?.Dispose(); - } } } diff --git a/NetCord/Gateway/WebSockets/IWebSocketConnection.cs b/NetCord/Gateway/WebSockets/IWebSocketConnection.cs index bc45fb6f5..caaf0508b 100644 --- a/NetCord/Gateway/WebSockets/IWebSocketConnection.cs +++ b/NetCord/Gateway/WebSockets/IWebSocketConnection.cs @@ -6,6 +6,8 @@ public interface IWebSocketConnection : IDisposable public string? CloseStatusDescription { get; } + public ValueTask OpenAsync(Uri uri, CancellationToken cancellationToken = default); + public ValueTask SendAsync(ReadOnlyMemory buffer, WebSocketMessageType messageType, WebSocketMessageFlags messageFlags, CancellationToken cancellationToken = default); public ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default); diff --git a/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs b/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs index 05a7eb8da..cd80fd1f8 100644 --- a/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs +++ b/NetCord/Gateway/WebSockets/IWebSocketConnectionProvider.cs @@ -2,5 +2,5 @@ public interface IWebSocketConnectionProvider { - public ValueTask CreateWebSocketConnectionAsync(Uri uri, CancellationToken cancellationToken = default); + public IWebSocketConnection CreateConnection(); } diff --git a/NetCord/Gateway/WebSockets/WebSocketConnection.cs b/NetCord/Gateway/WebSockets/WebSocketConnection.cs index 4dfffcc0c..9046ff1ac 100644 --- a/NetCord/Gateway/WebSockets/WebSocketConnection.cs +++ b/NetCord/Gateway/WebSockets/WebSocketConnection.cs @@ -4,24 +4,17 @@ namespace NetCord.Gateway.WebSockets; internal sealed class WebSocketConnection : IWebSocketConnection { - private readonly ClientWebSocket _webSocket; - - public static async ValueTask CreateAsync(Uri uri, CancellationToken cancellationToken = default) - { - ClientWebSocket webSocket = new(); - await webSocket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); - return new WebSocketConnection(webSocket); - } - - private WebSocketConnection(ClientWebSocket webSocket) - { - _webSocket = webSocket; - } + private readonly ClientWebSocket _webSocket = new(); public int? CloseStatus => (int?)_webSocket.CloseStatus; public string? CloseStatusDescription => _webSocket.CloseStatusDescription; + public ValueTask OpenAsync(Uri uri, CancellationToken cancellationToken = default) + { + return new(_webSocket.ConnectAsync(uri, cancellationToken)); + } + public void Abort() { _webSocket.Abort(); diff --git a/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs b/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs index 4a9a12ae1..bc1422e3a 100644 --- a/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs +++ b/NetCord/Gateway/WebSockets/WebSocketConnectionProvider.cs @@ -2,8 +2,8 @@ public class WebSocketConnectionProvider : IWebSocketConnectionProvider { - public ValueTask CreateWebSocketConnectionAsync(Uri uri, CancellationToken cancellationToken = default) + public IWebSocketConnection CreateConnection() { - return WebSocketConnection.CreateAsync(uri, cancellationToken); + return new WebSocketConnection(); } } diff --git a/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs index eb00d1303..834afe1b0 100644 --- a/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs +++ b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs @@ -1,8 +1,10 @@ namespace NetCord.Gateway.WebSockets; +[Flags] public enum WebSocketMessageFlags : byte { None = 0, - EndOfMessage = 1, - DisableCompression = 2, + EndOfMessage = 1 << 0, + DisableCompression = 1 << 1, + BypassReady = 1 << 7, } diff --git a/NetCord/Rest/RateLimits/GlobalRateLimiter.cs b/NetCord/Rest/RateLimits/GlobalRateLimiter.cs index ae67b00d6..49e5ea5d5 100644 --- a/NetCord/Rest/RateLimits/GlobalRateLimiter.cs +++ b/NetCord/Rest/RateLimits/GlobalRateLimiter.cs @@ -27,7 +27,7 @@ public ValueTask TryAcquireAsync() } } - return new(RateLimitAcquisitionResult.NoRateLimit()); + return new(RateLimitAcquisitionResult.NoRateLimit); } public ValueTask IndicateRateLimitAsync(long reset) diff --git a/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs b/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs index 58fa80c5c..7ee722dd8 100644 --- a/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs @@ -10,7 +10,7 @@ internal class NoRateLimitRouteRateLimiter : ITrackingRouteRateLimiter public ValueTask TryAcquireAsync() { - return new(RateLimitAcquisitionResult.NoRateLimit()); + return new(RateLimitAcquisitionResult.NoRateLimit); } public ValueTask CancelAcquireAsync(long timestamp) diff --git a/NetCord/Rest/RateLimits/RateLimitAcquisitionResult.cs b/NetCord/Rest/RateLimits/RateLimitAcquisitionResult.cs index 23d869c00..57236aa22 100644 --- a/NetCord/Rest/RateLimits/RateLimitAcquisitionResult.cs +++ b/NetCord/Rest/RateLimits/RateLimitAcquisitionResult.cs @@ -9,9 +9,9 @@ private RateLimitAcquisitionResult(int resetAfter, bool rateLimited, bool always AlwaysRetry = alwaysRetryOnce; } - public static RateLimitAcquisitionResult Retry() => new(0, false, true); + public static RateLimitAcquisitionResult Retry { get; } = new(0, false, true); - public static RateLimitAcquisitionResult NoRateLimit() => new(0, false, false); + public static RateLimitAcquisitionResult NoRateLimit { get; } = new(0, false, false); public static RateLimitAcquisitionResult RateLimit(int resetAfter) => new(resetAfter, true, false); diff --git a/NetCord/Rest/RateLimits/RouteRateLimiter.cs b/NetCord/Rest/RateLimits/RouteRateLimiter.cs index c7759f494..372eaaf01 100644 --- a/NetCord/Rest/RateLimits/RouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/RouteRateLimiter.cs @@ -34,7 +34,7 @@ public ValueTask TryAcquireAsync() _remaining--; } } - return new(RateLimitAcquisitionResult.NoRateLimit()); + return new(RateLimitAcquisitionResult.NoRateLimit); } public ValueTask CancelAcquireAsync(long timestamp) diff --git a/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs b/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs index f445c8d55..8718c6757 100644 --- a/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs @@ -15,11 +15,11 @@ public async ValueTask TryAcquireAsync() { await _semaphore.WaitAsync().ConfigureAwait(false); if (_retry) - return RateLimitAcquisitionResult.Retry(); + return RateLimitAcquisitionResult.Retry; _retry = true; - return RateLimitAcquisitionResult.NoRateLimit(); + return RateLimitAcquisitionResult.NoRateLimit; } public ValueTask CancelAcquireAsync(long timestamp) diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index d8930f907..6a50610a8 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -91,6 +91,15 @@ private static async Task Main() await _client.StartAsync(); await _client.ReadyAsync; + + //await _client.CloseAsync(); + + ////await _client.StartAsync(); + + //await _client.RequestGuildUsersAsync(new(0)); + + await Task.WhenAll(_client.CloseAsync(), _client.RequestGuildUsersAsync(new(0)).AsTask()); + //try //{ // await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); @@ -101,14 +110,14 @@ private static async Task Main() // Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); //} - for (int i = 0; i < 120; i++) - { - await _client.UpdatePresenceAsync(new(UserStatusType.Online) - { - Activities = [new($"wzium {i}", UserActivityType.Game)], - }); - Console.WriteLine(i); - } + //for (int i = 0; i < 120; i++) + //{ + // await _client.UpdatePresenceAsync(new(UserStatusType.Online) + // { + // Activities = [new($"wzium {i}", UserActivityType.Game)], + // }); + // Console.WriteLine(i); + //} await Task.Delay(-1); } From 4070cf6f924936bd8e6a634c5d2cb57c60f84b2a Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Sun, 25 Aug 2024 22:26:23 +0200 Subject: [PATCH 07/33] Remove commented code --- NetCord/Gateway/WebSocketClient.cs | 63 ------------------------------ 1 file changed, 63 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 4d2e03dbb..c108bc9bd 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -265,18 +265,6 @@ private protected Task StartAsync(CancellationToken cancellatio } return ConnectAsync(state, cancellationToken); - - //CancellationTokenProvider newTokenProvider = new(); - //if (Interlocked.CompareExchange(ref _closedTokenProvider, newTokenProvider, null) is not null) - //{ - // newTokenProvider.Dispose(); - // throw new InvalidOperationException("Connection already started."); - //} - - //if (Interlocked.CompareExchange(ref _state, new(), null) is not null) - // throw new InvalidOperationException("Connection already started."); - - //return ConnectAsync(cancellationToken); } private protected async Task ConnectAsync(State state, CancellationToken cancellationToken = default) @@ -295,12 +283,6 @@ private protected async Task ConnectAsync(State state, Cancella HandleConnected(state); connectionState.StartReading(state, ReadAsync); return connectionState; - - //HandleConnecting(); - //var connection = await _connectionProvider.CreateWebSocketConnectionAsync(Uri, cancellationToken).ConfigureAwait(false); - //var state = _state = new(connection); - //HandleConnected(); - //state.StartReading(ReadAsync); } /// @@ -442,51 +424,6 @@ public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPa if (!properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) ThrowConnectionNotStarted(); - - //var rateLimiter = connectionState.RateLimiter; - - //if (state is null) - //{ - // //if (_closedTokenProvider is null) - // // throw new InvalidOperationException("Connection not started."); - - // //if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) - // //{ - // // await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // - // // continue; - // //} - - // //throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting."); - //} - - //var result = await rateLimiter.TryAcquireAsync().ConfigureAwait(false); - - //if (result.RateLimited) - //{ - // if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryRateLimit)) - // { - // await Task.Delay(result.ResetAfter, cancellationToken).ConfigureAwait(false); - // continue; - // } - - // throw new InvalidOperationException("Rate limit triggered."); - //} - - //try - //{ - // await connectionState.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, cancellationToken).ConfigureAwait(false); - //} - //catch (Exception ex) when (ex is not ArgumentException) - //{ - // cancellationToken.ThrowIfCancellationRequested(); - - // if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) - // continue; - - // throw new InvalidOperationException($"The {nameof(WebSocketClient)} is reconnecting.", ex); - //} - - //return; } } From 81fcb6289c867d5e508c2cdf7e43a6f35376badf Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Sun, 25 Aug 2024 22:40:32 +0200 Subject: [PATCH 08/33] Use auto property --- NetCord/Gateway/WebSocketClient.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index c108bc9bd..512a6cd36 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -59,9 +59,7 @@ public void Dispose() private protected sealed class State : IDisposable { - private ConnectionState? _connectionState; - - public ConnectionState? ConnectionState => _connectionState; + public ConnectionState? ConnectionState { get; private set; } public CancellationTokenProvider ClosedTokenProvider { get; } = new(); @@ -77,7 +75,7 @@ public void IndicateConnected(ConnectionState connectionState) { lock (ClosedTokenProvider) { - if (_connectionState != connectionState) + if (ConnectionState != connectionState) return; _connectedCompletionSource.TrySetResult(connectionState); @@ -88,7 +86,7 @@ public void IndicateReady(ConnectionState connectionState) { lock (ClosedTokenProvider) { - if (_connectionState != connectionState) + if (ConnectionState != connectionState) return; _readyCompletionSource.TrySetResult(connectionState); @@ -99,11 +97,11 @@ public bool TryIndicateConnecting(ConnectionState connectionState) { lock (ClosedTokenProvider) { - var previousState = _connectionState; + var previousState = ConnectionState; if (previousState is not null) return false; - _connectionState = connectionState; + ConnectionState = connectionState; } return true; @@ -113,14 +111,14 @@ public bool TryIndicateDisconnecting([MaybeNullWhen(false)] out ConnectionState { lock (ClosedTokenProvider) { - var previousState = _connectionState; + var previousState = ConnectionState; if (previousState is null || !previousState.TryIndicateDisconnecting()) { connectionState = null; return false; } - _connectionState = null; + ConnectionState = null; connectionState = previousState; _readyCompletionSource.TrySetCanceled(); @@ -135,7 +133,7 @@ public bool TryIndicateDisconnecting([MaybeNullWhen(false)] out ConnectionState public void Dispose() { - _connectionState?.Dispose(); + ConnectionState?.Dispose(); ClosedTokenProvider.Dispose(); } } From 6864f6d6b6c12acc8e5de7d804239e344311466e Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Mon, 26 Aug 2024 10:05:51 +0200 Subject: [PATCH 09/33] Improve the WebSocketClient --- NetCord/Gateway/CancellationTokenProvider.cs | 2 + NetCord/Gateway/WebSocketClient.cs | 147 +++++++++++++++---- Tests/NetCord.Test/Program.cs | 25 ++-- 3 files changed, 133 insertions(+), 41 deletions(-) diff --git a/NetCord/Gateway/CancellationTokenProvider.cs b/NetCord/Gateway/CancellationTokenProvider.cs index 36e19444d..21d7634a7 100644 --- a/NetCord/Gateway/CancellationTokenProvider.cs +++ b/NetCord/Gateway/CancellationTokenProvider.cs @@ -4,6 +4,8 @@ internal sealed class CancellationTokenProvider : IDisposable { private readonly CancellationTokenSource _source; + public bool IsCancellationRequested => _source.IsCancellationRequested; + public CancellationToken Token { get; } public CancellationTokenProvider() diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 512a6cd36..8ff39ece8 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -51,6 +51,7 @@ public bool TryIndicateDisconnecting() public void Dispose() { + _readCompletionSource.TrySetResult(); DisconnectedTokenProvider.Dispose(); RateLimiter.Dispose(); Connection.Dispose(); @@ -93,15 +94,60 @@ public void IndicateReady(ConnectionState connectionState) } } - public bool TryIndicateConnecting(ConnectionState connectionState) + public ConnectingResult TryIndicateConnecting(ConnectionState connectionState) { lock (ClosedTokenProvider) { + if (ClosedTokenProvider.IsCancellationRequested) + return ConnectingResult.Closed; + + if (ConnectionState is ConnectionState previousState) + return ConnectingResult.AlreadyStarted; + + ConnectionState = connectionState; + } + + return ConnectingResult.Success; + } + + public enum ConnectingResult : byte + { + Success, + AlreadyStarted, + Closed, + } + + public void IndicateConnectingFailed(ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + _ = connectionState.TryIndicateDisconnecting(); + + if (ConnectionState != connectionState) + return; + + ConnectionState = null; + } + } + + public bool TryIndicateClosing([MaybeNullWhen(false)] out ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + ClosedTokenProvider.Cancel(); + + _readyCompletionSource.TrySetCanceled(); + _connectedCompletionSource.TrySetCanceled(); + var previousState = ConnectionState; - if (previousState is not null) + if (previousState is null || !previousState.TryIndicateDisconnecting()) + { + connectionState = null; return false; + } - ConnectionState = connectionState; + ConnectionState = null; + connectionState = previousState; } return true; @@ -203,16 +249,21 @@ private async void HandleConnected(State state) await InvokeEventAsync(Connect).ConfigureAwait(false); } - private async void HandleDisconnected(State state, WebSocketCloseStatus? closeStatus, string? description) + private async void HandleDisconnected(State state, ConnectionState connectionState, WebSocketCloseStatus? closeStatus, string? description) { InvokeLog(LogMessage.Info("Disconnected", string.IsNullOrEmpty(description) ? null : (description.EndsWith('.') ? description[..^1] : description))); var reconnect = Reconnect(closeStatus, description); var disconnectTask = InvokeEventAsync(Disconnect, reconnect); if (reconnect) + { + connectionState.Dispose(); await ReconnectAsync(state).ConfigureAwait(false); + } else { - _state = null; + Interlocked.CompareExchange(ref _state, null, state); + state.Dispose(); + connectionState.Dispose(); _readyCompletionSource.TrySetCanceled(); } @@ -253,7 +304,7 @@ private async void HandleMessageReceived(State state, ReadOnlyMemory data) } } - private protected Task StartAsync(CancellationToken cancellationToken = default) + private protected async Task StartAsync(CancellationToken cancellationToken = default) { State state = new(); if (Interlocked.CompareExchange(ref _state, state, null) is not null) @@ -262,7 +313,19 @@ private protected Task StartAsync(CancellationToken cancellatio ThrowConnectionAlreadyStarted(); } - return ConnectAsync(state, cancellationToken); + ConnectionState connectionState; + try + { + connectionState = await ConnectAsync(state, cancellationToken).ConfigureAwait(false); + } + catch + { + Interlocked.CompareExchange(ref _state, null, state); + state.Dispose(); + throw; + } + + return connectionState; } private protected async Task ConnectAsync(State state, CancellationToken cancellationToken = default) @@ -270,16 +333,36 @@ private protected async Task ConnectAsync(State state, Cancella var connection = _connectionProvider.CreateConnection(); var rateLimiter = _rateLimiterProvider.CreateRateLimiter(); ConnectionState connectionState = new(connection, rateLimiter); - if (!state.TryIndicateConnecting(connectionState)) + + switch (state.TryIndicateConnecting(connectionState)) { + case State.ConnectingResult.Success: + break; + case State.ConnectingResult.AlreadyStarted: + connectionState.Dispose(); + ThrowConnectionAlreadyStarted(); + break; + case State.ConnectingResult.Closed: + connectionState.Dispose(); + ThrowConnectionNotStarted(); + break; + } + + try + { + HandleConnecting(); + await connection.OpenAsync(Uri, cancellationToken).ConfigureAwait(false); + HandleConnected(state); + } + catch (Exception) + { + state.IndicateConnectingFailed(connectionState); connectionState.Dispose(); - ThrowConnectionAlreadyStarted(); + throw; } - HandleConnecting(); - await connection.OpenAsync(Uri, cancellationToken).ConfigureAwait(false); - HandleConnected(state); connectionState.StartReading(state, ReadAsync); + return connectionState; } @@ -297,24 +380,27 @@ public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus. if (state is null) ThrowConnectionNotStarted(); - if (!state.TryIndicateDisconnecting(out var connectionState)) + if (!state.TryIndicateClosing(out var connectionState)) return; - var connection = connectionState.Connection; - try + using (state) { - await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); - } - catch - { - connection.Abort(); - HandleClosed(); - throw; - } + var connection = connectionState.Connection; + try + { + await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); + } + catch + { + connection.Abort(); + HandleClosed(); + throw; + } - await connectionState.ReadTask.ConfigureAwait(false); + await connectionState.ReadTask.ConfigureAwait(false); - HandleClosed(); + HandleClosed(); + } } private async Task ReadAsync(State state) @@ -347,10 +433,7 @@ private async Task ReadAsync(State state) } if (state.TryIndicateDisconnecting(out _)) - { - connectionState.Dispose(); - HandleDisconnected(state, (WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); - } + HandleDisconnected(state, connectionState, (WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); } public void Abort() @@ -360,8 +443,9 @@ public void Abort() if (state is null) return; - if (state.TryIndicateDisconnecting(out var connectionState)) + if (state.TryIndicateClosing(out var connectionState)) { + state.Dispose(); connectionState.Connection.Abort(); HandleClosed(); } @@ -488,8 +572,7 @@ private protected static async ValueTask SendConnectionPayloadAsync(ConnectionSt private protected async ValueTask ReconnectAsync(State state) { - if (state is not { ClosedTokenProvider.Token: var cancellationToken }) - return; + var cancellationToken = state.ClosedTokenProvider.Token; foreach (var delay in _reconnectStrategy.GetDelays()) { diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index 6a50610a8..437b97964 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -98,7 +98,7 @@ private static async Task Main() //await _client.RequestGuildUsersAsync(new(0)); - await Task.WhenAll(_client.CloseAsync(), _client.RequestGuildUsersAsync(new(0)).AsTask()); + //await Task.WhenAll(_client.CloseAsync(), _client.RequestGuildUsersAsync(new(0)).AsTask()); //try //{ @@ -110,14 +110,21 @@ private static async Task Main() // Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); //} - //for (int i = 0; i < 120; i++) - //{ - // await _client.UpdatePresenceAsync(new(UserStatusType.Online) - // { - // Activities = [new($"wzium {i}", UserActivityType.Game)], - // }); - // Console.WriteLine(i); - //} + for (int i = 0; i < 119; i++) + { + await _client.UpdatePresenceAsync(new(UserStatusType.Online) + { + Activities = [new($"wzium {i}", UserActivityType.Game)], + }); + Console.WriteLine(i); + } + + await _client.CloseAsync(); + + await _client.UpdatePresenceAsync(new(UserStatusType.Online) + { + Activities = [new($"wzium", UserActivityType.Game)], + }); await Task.Delay(-1); } From b035bc221e28812fcf01e715127328a43be9d0c8 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Mon, 26 Aug 2024 10:23:06 +0200 Subject: [PATCH 10/33] Simplify null check --- NetCord/Gateway/WebSocketClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 8ff39ece8..075268654 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -101,7 +101,7 @@ public ConnectingResult TryIndicateConnecting(ConnectionState connectionState) if (ClosedTokenProvider.IsCancellationRequested) return ConnectingResult.Closed; - if (ConnectionState is ConnectionState previousState) + if (ConnectionState is not null) return ConnectingResult.AlreadyStarted; ConnectionState = connectionState; From 8371f4b1f2a89e324cc2de4d9b24970489114f5b Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Mon, 26 Aug 2024 11:26:48 +0200 Subject: [PATCH 11/33] Refactor connection handling in WebSocketClient --- NetCord/Gateway/GatewayClient.cs | 2 -- NetCord/Gateway/Voice/VoiceClient.cs | 1 - NetCord/Gateway/WebSocketClient.cs | 52 +++++++++++++++++++--------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/NetCord/Gateway/GatewayClient.cs b/NetCord/Gateway/GatewayClient.cs index 77b379fb2..100758faa 100644 --- a/NetCord/Gateway/GatewayClient.cs +++ b/NetCord/Gateway/GatewayClient.cs @@ -993,7 +993,6 @@ await InvokeEventAsync(Ready, args, data => ApplicationFlags = args.ApplicationFlags; state.IndicateReady(state.ConnectionState!); - _readyCompletionSource.TrySetResult(); }).ConfigureAwait(false); await updateLatencyTask.ConfigureAwait(false); } @@ -1006,7 +1005,6 @@ await InvokeEventAsync(Ready, args, data => var resumeTask = InvokeResumeEventAsync(); state.IndicateReady(state.ConnectionState!); - _readyCompletionSource.TrySetResult(); await updateLatencyTask.ConfigureAwait(false); await resumeTask.ConfigureAwait(false); diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index d4c9a760b..ebbc7679b 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -164,7 +164,6 @@ void GetIpAndPort(out string ip, out ushort port) var readyTask = InvokeEventAsync(Ready); state.IndicateReady(state.ConnectionState!); - _readyCompletionSource.TrySetResult(); await readyTask.ConfigureAwait(false); } diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 075268654..2f78a1d1d 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -153,6 +153,25 @@ public bool TryIndicateClosing([MaybeNullWhen(false)] out ConnectionState connec return true; } + public bool TryIndicateDisconnecting(ConnectionState connectionState) + { + lock (ClosedTokenProvider) + { + if (ConnectionState != connectionState || !connectionState.TryIndicateDisconnecting()) + return false; + + ConnectionState = null; + + _readyCompletionSource.TrySetCanceled(); + _readyCompletionSource = new(); + + _connectedCompletionSource.TrySetCanceled(); + _connectedCompletionSource = new(); + } + + return true; + } + public bool TryIndicateDisconnecting([MaybeNullWhen(false)] out ConnectionState connectionState) { lock (ClosedTokenProvider) @@ -208,14 +227,11 @@ private protected WebSocketClient(IWebSocketClientConfiguration configuration) private readonly WebSocketPayloadProperties _defaultPayloadProperties; private protected readonly ILatencyTimer _latencyTimer; - private protected readonly TaskCompletionSource _readyCompletionSource = new(); private State? _state; private protected abstract Uri Uri { get; } - public Task ReadyAsync => _readyCompletionSource.Task; - public TimeSpan Latency { get @@ -249,22 +265,27 @@ private async void HandleConnected(State state) await InvokeEventAsync(Connect).ConfigureAwait(false); } - private async void HandleDisconnected(State state, ConnectionState connectionState, WebSocketCloseStatus? closeStatus, string? description) + private async void HandleDisconnected(State state, ConnectionState connectionState) { + var connection = connectionState.Connection; + + var description = connection.CloseStatusDescription; InvokeLog(LogMessage.Info("Disconnected", string.IsNullOrEmpty(description) ? null : (description.EndsWith('.') ? description[..^1] : description))); - var reconnect = Reconnect(closeStatus, description); + + var reconnect = Reconnect((WebSocketCloseStatus?)connection.CloseStatus, description); + var disconnectTask = InvokeEventAsync(Disconnect, reconnect); + if (reconnect) { - connectionState.Dispose(); await ReconnectAsync(state).ConfigureAwait(false); + connectionState.Dispose(); } else { Interlocked.CompareExchange(ref _state, null, state); state.Dispose(); connectionState.Dispose(); - _readyCompletionSource.TrySetCanceled(); } await disconnectTask.ConfigureAwait(false); @@ -273,11 +294,7 @@ private async void HandleDisconnected(State state, ConnectionState connectionSta private async void HandleClosed() { InvokeLog(LogMessage.Info("Closed")); - var closeTask = InvokeEventAsync(Close).ConfigureAwait(false); - - _readyCompletionSource.TrySetCanceled(); - - await closeTask; + await InvokeEventAsync(Close).ConfigureAwait(false); } private async void HandleMessageReceived(State state, ReadOnlyMemory data) @@ -390,11 +407,12 @@ public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus. { await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); } - catch + catch (Exception ex) when (ex is not ArgumentException) { + InvokeLog(LogMessage.Error(ex)); connection.Abort(); HandleClosed(); - throw; + return; } await connectionState.ReadTask.ConfigureAwait(false); @@ -432,8 +450,10 @@ private async Task ReadAsync(State state) { } - if (state.TryIndicateDisconnecting(out _)) - HandleDisconnected(state, connectionState, (WebSocketCloseStatus?)connection.CloseStatus, connection.CloseStatusDescription); + if (state.TryIndicateDisconnecting(connectionState)) + HandleDisconnected(state, connectionState); + else + connectionState.Dispose(); } public void Abort() From 39b6c5b4492fdf3d1a6fd06f3409528f0efbd324 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Mon, 26 Aug 2024 11:29:25 +0200 Subject: [PATCH 12/33] Update tests and guides --- Documentation/guides/advanced/Voice/VoiceModule.cs | 6 ------ Documentation/guides/advanced/voice.md | 4 ++-- Tests/NetCord.Test/ApplicationCommands/VoiceCommands.cs | 1 - Tests/NetCord.Test/Program.cs | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/Documentation/guides/advanced/Voice/VoiceModule.cs b/Documentation/guides/advanced/Voice/VoiceModule.cs index 3da53139a..f49239e2f 100644 --- a/Documentation/guides/advanced/Voice/VoiceModule.cs +++ b/Documentation/guides/advanced/Voice/VoiceModule.cs @@ -34,9 +34,6 @@ public async Task PlayAsync(string track) // Connect await voiceClient.StartAsync(); - // Wait for ready - await voiceClient.ReadyAsync; - // Enter speaking state, to be able to send voice await voiceClient.EnterSpeakingStateAsync(SpeakingFlags.Microphone); @@ -124,9 +121,6 @@ public async Task EchoAsync() // Connect await voiceClient.StartAsync(); - // Wait for ready - await voiceClient.ReadyAsync; - // Enter speaking state, to be able to send voice await voiceClient.EnterSpeakingStateAsync(SpeakingFlags.Microphone); diff --git a/Documentation/guides/advanced/voice.md b/Documentation/guides/advanced/voice.md index 6006001b0..d55ad8495 100644 --- a/Documentation/guides/advanced/voice.md +++ b/Documentation/guides/advanced/voice.md @@ -10,7 +10,7 @@ Follow the [installation guide](installing-native-dependencies.md) to install th > In the following examples streams and @NetCord.Gateway.Voice.VoiceClient instances are not disposed because they should be stored somewhere and disposed later. ### Sending Voice -[!code-cs[VoiceModule.cs](Voice/VoiceModule.cs#L12-L102)] +[!code-cs[VoiceModule.cs](Voice/VoiceModule.cs#L12-L99)] ### Receiving Voice -[!code-cs[VoiceModule.cs](Voice/VoiceModule.cs#L104-L146)] \ No newline at end of file +[!code-cs[VoiceModule.cs](Voice/VoiceModule.cs#L101-L141)] \ No newline at end of file diff --git a/Tests/NetCord.Test/ApplicationCommands/VoiceCommands.cs b/Tests/NetCord.Test/ApplicationCommands/VoiceCommands.cs index 135084753..8c1c88f4b 100644 --- a/Tests/NetCord.Test/ApplicationCommands/VoiceCommands.cs +++ b/Tests/NetCord.Test/ApplicationCommands/VoiceCommands.cs @@ -57,7 +57,6 @@ private async Task JoinAsync(VoiceEncryption encryption, Func Date: Mon, 26 Aug 2024 12:27:53 +0200 Subject: [PATCH 13/33] Fix guide --- Documentation/guides/advanced/voice.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/guides/advanced/voice.md b/Documentation/guides/advanced/voice.md index d55ad8495..4c80523f5 100644 --- a/Documentation/guides/advanced/voice.md +++ b/Documentation/guides/advanced/voice.md @@ -13,4 +13,4 @@ Follow the [installation guide](installing-native-dependencies.md) to install th [!code-cs[VoiceModule.cs](Voice/VoiceModule.cs#L12-L99)] ### Receiving Voice -[!code-cs[VoiceModule.cs](Voice/VoiceModule.cs#L101-L141)] \ No newline at end of file +[!code-cs[VoiceModule.cs](Voice/VoiceModule.cs#L101-L140)] \ No newline at end of file From ea5786994273b9788527939037f465c0e9ca1e0f Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Mon, 26 Aug 2024 13:05:02 +0200 Subject: [PATCH 14/33] Complete tasks in dispose --- NetCord/Gateway/WebSocketClient.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 2f78a1d1d..7c95442ad 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -198,6 +198,8 @@ public bool TryIndicateDisconnecting([MaybeNullWhen(false)] out ConnectionState public void Dispose() { + _readyCompletionSource.TrySetCanceled(); + _connectedCompletionSource.TrySetCanceled(); ConnectionState?.Dispose(); ClosedTokenProvider.Dispose(); } From d1ea3bb01d0822601da74d5718b6a287f6d9d21d Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Mon, 26 Aug 2024 13:22:33 +0200 Subject: [PATCH 15/33] Remove cancellation token from reading task --- NetCord/Gateway/WebSocketClient.cs | 3 +-- Tests/NetCord.Test/Program.cs | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 7c95442ad..ab85a2a86 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -427,13 +427,12 @@ private async Task ReadAsync(State state) { var connectionState = state.ConnectionState!; var connection = connectionState.Connection; - var token = connectionState.DisconnectedTokenProvider.Token; try { using RentedArrayBufferWriter writer = new(DefaultBufferSize); while (true) { - var result = await connection.ReceiveAsync(writer.GetMemory(), token).ConfigureAwait(false); + var result = await connection.ReceiveAsync(writer.GetMemory()).ConfigureAwait(false); if (result.EndOfMessage) { diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index 65a0248bf..76eb36997 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -99,15 +99,15 @@ private static async Task Main() //await Task.WhenAll(_client.CloseAsync(), _client.RequestGuildUsersAsync(new(0)).AsTask()); - //try - //{ - // await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); - //} - //catch (RestException ex) - //{ - // var error = ex.Error; - // Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); - //} + try + { + await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); + } + catch (RestException ex) + { + var error = ex.Error; + Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); + } for (int i = 0; i < 119; i++) { @@ -119,6 +119,7 @@ await _client.UpdatePresenceAsync(new(UserStatusType.Online) } await _client.CloseAsync(); + await _client.StartAsync(); await _client.UpdatePresenceAsync(new(UserStatusType.Online) { From 753f3312d3341f81591d5b363d5f9497091ee085 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Mon, 26 Aug 2024 13:34:16 +0200 Subject: [PATCH 16/33] Fix voice client --- NetCord/Gateway/Voice/VoiceClient.cs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index ebbc7679b..0fd2c9aa3 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Net.Sockets; +using System.Text; using NetCord.Gateway.JsonModels; using NetCord.Gateway.Voice.Encryption; @@ -52,11 +53,11 @@ public class VoiceClient : WebSocketClient RedirectInputStreams = configuration.RedirectInputStreams; } - private ValueTask SendIdentifyAsync(CancellationToken cancellationToken = default) + private ValueTask SendIdentifyAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Identify, new(GuildId, UserId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceIdentifyProperties); _latencyTimer.Start(); - return SendPayloadAsync(serializedPayload, _internalPayloadProperties, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } /// @@ -65,8 +66,8 @@ private ValueTask SendIdentifyAsync(CancellationToken cancellationToken = defaul /// public async new Task StartAsync(CancellationToken cancellationToken = default) { - await base.StartAsync(cancellationToken).ConfigureAwait(false); - await SendIdentifyAsync(cancellationToken).ConfigureAwait(false); + var connectionState = await base.StartAsync(cancellationToken).ConfigureAwait(false); + await SendIdentifyAsync(connectionState, cancellationToken).ConfigureAwait(false); } /// @@ -103,12 +104,14 @@ private protected override async Task ProcessPayloadAsync(State state, JsonPaylo case VoiceOpcode.Ready: { var latency = _latencyTimer.Elapsed; - await UpdateLatencyAsync(latency).ConfigureAwait(false); + var updateLatencyTask = UpdateLatencyAsync(latency).ConfigureAwait(false); var ready = payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonReady); var ssrc = ready.Ssrc; Cache = Cache.CacheCurrentSsrc(ssrc); _udpSocket.Connect(ready.Ip, ready.Port); + + var connectionState = state.ConnectionState!; if (RedirectInputStreams) { TaskCompletionSource result = new(); @@ -125,7 +128,7 @@ private protected override async Task ProcessPayloadAsync(State state, JsonPaylo _udpSocket.DatagramReceive += HandleDatagramReceive; VoicePayloadProperties protocolPayload = new(VoiceOpcode.SelectProtocol, new("udp", new(ip, port, _encryption.Name))); - await SendPayloadAsync(protocolPayload.Serialize(Serialization.Default.VoicePayloadPropertiesProtocolProperties)).ConfigureAwait(false); + await SendConnectionPayloadAsync(connectionState, protocolPayload.Serialize(Serialization.Default.VoicePayloadPropertiesProtocolProperties), _internalPayloadProperties).ConfigureAwait(false); ReadOnlyMemory CreateDatagram() { @@ -145,15 +148,17 @@ void HandleDatagramReceiveOnce(UdpReceiveResult datagram) void GetIpAndPort(out string ip, out ushort port) { Span span = new(datagram); - ip = System.Text.Encoding.UTF8.GetString(span[8..72].TrimEnd((byte)0)); + ip = Encoding.UTF8.GetString(span[8..72].TrimEnd((byte)0)); port = BinaryPrimitives.ReadUInt16BigEndian(span[72..]); } } else { VoicePayloadProperties protocolPayload = new(VoiceOpcode.SelectProtocol, new("udp", new(ready.Ip, ready.Port, _encryption.Name))); - await SendPayloadAsync(protocolPayload.Serialize(Serialization.Default.VoicePayloadPropertiesProtocolProperties)).ConfigureAwait(false); + await SendConnectionPayloadAsync(connectionState, protocolPayload.Serialize(Serialization.Default.VoicePayloadPropertiesProtocolProperties), _internalPayloadProperties).ConfigureAwait(false); } + + await updateLatencyTask; } break; case VoiceOpcode.SessionDescription: From 8ef83be0c5b5f1315fa118946b44d41df7abaad8 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Mon, 26 Aug 2024 13:38:33 +0200 Subject: [PATCH 17/33] Optimize --- NetCord/Gateway/Voice/VoiceClient.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index 0fd2c9aa3..56e2018c1 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -182,9 +182,11 @@ void GetIpAndPort(out string ip, out ushort port) VoiceInStream voiceInStream = new(this, ssrc, userId); DecryptStream decryptStream = new(voiceInStream, _encryption); - if (_inputStreams.Remove(ssrc, out var stream)) + + var inputStreams = _inputStreams; + if (inputStreams.Remove(ssrc, out var stream)) stream.Dispose(); - _inputStreams[ssrc] = decryptStream; + inputStreams[ssrc] = decryptStream; } break; case VoiceOpcode.HeartbeatACK: From 9eed3d54e2483ad1b7daaa797cc80a633d81ea95 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Wed, 28 Aug 2024 13:30:33 +0200 Subject: [PATCH 18/33] Improve payload sending --- NetCord/Gateway/WebSocketClient.cs | 59 +++++++++++++++++++++++------- Tests/NetCord.Test/Program.cs | 2 +- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index ab85a2a86..7cbee15c5 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -13,6 +13,20 @@ namespace NetCord.Gateway; public abstract class WebSocketClient : IDisposable { + private protected record ConnectionStateResult + { + public record Success(ConnectionState ConnectionState) : ConnectionStateResult; + + public record Retry : ConnectionStateResult + { + private Retry() + { + } + + public static Retry Instance { get; } = new(); + } + } + private protected sealed class ConnectionState(IWebSocketConnection connection, IRateLimiter rateLimiter) : IDisposable { public IWebSocketConnection Connection => connection; @@ -64,13 +78,13 @@ private protected sealed class State : IDisposable public CancellationTokenProvider ClosedTokenProvider { get; } = new(); - public Task ReadyTask => _readyCompletionSource.Task; + public Task ReadyTask => _readyCompletionSource.Task; - private TaskCompletionSource _readyCompletionSource = new(); + private TaskCompletionSource _readyCompletionSource = new(); - public Task ConnectedTask => _connectedCompletionSource.Task; + public Task ConnectedTask => _connectedCompletionSource.Task; - private TaskCompletionSource _connectedCompletionSource = new(); + private TaskCompletionSource _connectedCompletionSource = new(); public void IndicateConnected(ConnectionState connectionState) { @@ -79,7 +93,7 @@ public void IndicateConnected(ConnectionState connectionState) if (ConnectionState != connectionState) return; - _connectedCompletionSource.TrySetResult(connectionState); + _connectedCompletionSource.TrySetResult(new ConnectionStateResult.Success(connectionState)); } } @@ -90,7 +104,7 @@ public void IndicateReady(ConnectionState connectionState) if (ConnectionState != connectionState) return; - _readyCompletionSource.TrySetResult(connectionState); + _readyCompletionSource.TrySetResult(new ConnectionStateResult.Success(connectionState)); } } @@ -162,10 +176,11 @@ public bool TryIndicateDisconnecting(ConnectionState connectionState) ConnectionState = null; - _readyCompletionSource.TrySetCanceled(); - _readyCompletionSource = new(); + var retry = ConnectionStateResult.Retry.Instance; + _readyCompletionSource.TrySetResult(retry); + _connectedCompletionSource.TrySetResult(retry); - _connectedCompletionSource.TrySetCanceled(); + _readyCompletionSource = new(); _connectedCompletionSource = new(); } @@ -186,10 +201,11 @@ public bool TryIndicateDisconnecting([MaybeNullWhen(false)] out ConnectionState ConnectionState = null; connectionState = previousState; - _readyCompletionSource.TrySetCanceled(); - _readyCompletionSource = new(); + var retry = ConnectionStateResult.Retry.Instance; + _readyCompletionSource.TrySetResult(retry); + _connectedCompletionSource.TrySetResult(retry); - _connectedCompletionSource.TrySetCanceled(); + _readyCompletionSource = new(); _connectedCompletionSource = new(); } @@ -510,7 +526,13 @@ public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPa if (!task.IsCompleted) { if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) - connectionState = await task.ConfigureAwait(false); + { + var result = await task.ConfigureAwait(false); + if (result is ConnectionStateResult.Success successResult) + connectionState = successResult.ConnectionState; + else + continue; + } else { ThrowConnectionNotStarted(); @@ -518,7 +540,16 @@ public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPa } } else - connectionState = state.ConnectionState!; + { + var result = task.Result; + if (result is ConnectionStateResult.Success successResult) + connectionState = successResult.ConnectionState; + else + { + ThrowConnectionNotStarted(); + return; + } + } var exception = await TrySendConnectionPayloadAsync(connectionState, buffer, properties, cancellationToken).ConfigureAwait(false); diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index 76eb36997..c0a99b546 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -21,7 +21,7 @@ internal static class Program { Intents = GatewayIntents.All, ConnectionProperties = ConnectionPropertiesProperties.IOS, - Compression = new ZLibGatewayCompression(), + Compression = new ZstandardGatewayCompression(), }); private static readonly CommandService _commandService = new(); From 3a6876ea367d054ecca42d0908275ab011fd1ad2 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Wed, 28 Aug 2024 14:52:08 +0200 Subject: [PATCH 19/33] Resolve possible concurrency issues --- NetCord/Gateway/GatewayClient.cs | 16 ++--- NetCord/Gateway/Voice/VoiceClient.cs | 7 +-- NetCord/Gateway/WebSocketClient.cs | 88 ++++++++-------------------- 3 files changed, 37 insertions(+), 74 deletions(-) diff --git a/NetCord/Gateway/GatewayClient.cs b/NetCord/Gateway/GatewayClient.cs index 100758faa..63b6f5dc2 100644 --- a/NetCord/Gateway/GatewayClient.cs +++ b/NetCord/Gateway/GatewayClient.cs @@ -899,7 +899,7 @@ private protected override ValueTask HeartbeatAsync(ConnectionState connectionSt private protected override JsonPayload CreatePayload(ReadOnlyMemory payload) => JsonSerializer.Deserialize(_compression.Decompress(payload).Span, Serialization.Default.JsonPayload)!; - private protected override async Task ProcessPayloadAsync(State state, JsonPayload payload) + private protected override async Task ProcessPayloadAsync(State state, ConnectionState connectionState, JsonPayload payload) { switch ((GatewayOpcode)payload.Opcode) { @@ -907,7 +907,7 @@ private protected override async Task ProcessPayloadAsync(State state, JsonPaylo SequenceNumber = payload.SequenceNumber.GetValueOrDefault(); try { - await ProcessEventAsync(state, payload).ConfigureAwait(false); + await ProcessEventAsync(state, connectionState, payload).ConfigureAwait(false); } catch (Exception ex) { @@ -918,13 +918,13 @@ private protected override async Task ProcessPayloadAsync(State state, JsonPaylo break; case GatewayOpcode.Reconnect: InvokeLog(LogMessage.Info("Reconnect request")); - await AbortAndReconnectAsync(state).ConfigureAwait(false); + await AbortAndReconnectAsync(state, connectionState).ConfigureAwait(false); break; case GatewayOpcode.InvalidSession: InvokeLog(LogMessage.Info("Invalid session")); try { - await SendIdentifyAsync(state.ConnectionState!).ConfigureAwait(false); + await SendIdentifyAsync(connectionState).ConfigureAwait(false); } catch (Exception ex) { @@ -932,7 +932,7 @@ private protected override async Task ProcessPayloadAsync(State state, JsonPaylo } break; case GatewayOpcode.Hello: - StartHeartbeating(state.ConnectionState!, payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); + StartHeartbeating(connectionState, payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); break; case GatewayOpcode.HeartbeatACK: await UpdateLatencyAsync(_latencyTimer.Elapsed).ConfigureAwait(false); @@ -970,7 +970,7 @@ public ValueTask RequestGuildUsersAsync(GuildUsersRequestProperties requestPrope return SendPayloadAsync(payload.Serialize(Serialization.Default.GatewayPayloadPropertiesGuildUsersRequestProperties), properties, cancellationToken); } - private async Task ProcessEventAsync(State state, JsonPayload payload) + private async Task ProcessEventAsync(State state, ConnectionState connectionState, JsonPayload payload) { var data = payload.Data.GetValueOrDefault(); var name = payload.Event!; @@ -992,7 +992,7 @@ await InvokeEventAsync(Ready, args, data => SessionId = args.SessionId; ApplicationFlags = args.ApplicationFlags; - state.IndicateReady(state.ConnectionState!); + state.IndicateReady(connectionState); }).ConfigureAwait(false); await updateLatencyTask.ConfigureAwait(false); } @@ -1004,7 +1004,7 @@ await InvokeEventAsync(Ready, args, data => var updateLatencyTask = UpdateLatencyAsync(latency); var resumeTask = InvokeResumeEventAsync(); - state.IndicateReady(state.ConnectionState!); + state.IndicateReady(connectionState); await updateLatencyTask.ConfigureAwait(false); await resumeTask.ConfigureAwait(false); diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index 56e2018c1..e83ef8472 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -97,7 +97,7 @@ private protected override ValueTask HeartbeatAsync(ConnectionState connectionSt return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } - private protected override async Task ProcessPayloadAsync(State state, JsonPayload payload) + private protected override async Task ProcessPayloadAsync(State state, ConnectionState connectionState, JsonPayload payload) { switch ((VoiceOpcode)payload.Opcode) { @@ -111,7 +111,6 @@ private protected override async Task ProcessPayloadAsync(State state, JsonPaylo _udpSocket.Connect(ready.Ip, ready.Port); - var connectionState = state.ConnectionState!; if (RedirectInputStreams) { TaskCompletionSource result = new(); @@ -168,7 +167,7 @@ void GetIpAndPort(out string ip, out ushort port) InvokeLog(LogMessage.Info("Ready")); var readyTask = InvokeEventAsync(Ready); - state.IndicateReady(state.ConnectionState!); + state.IndicateReady(connectionState); await readyTask.ConfigureAwait(false); } @@ -196,7 +195,7 @@ void GetIpAndPort(out string ip, out ushort port) break; case VoiceOpcode.Hello: { - StartHeartbeating(state.ConnectionState!, payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); + StartHeartbeating(connectionState, payload.Data.GetValueOrDefault().ToObject(Serialization.Default.JsonHello).HeartbeatInterval); } break; case VoiceOpcode.Resumed: diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 7cbee15c5..d4f2880f3 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -41,11 +41,11 @@ private protected sealed class ConnectionState(IWebSocketConnection connection, private int _state; - public async void StartReading(State state, Func readAsync) + public async void StartReading(State state, Func readAsync) { try { - await readAsync(state).ConfigureAwait(false); + await readAsync(state, this).ConfigureAwait(false); } finally { @@ -74,7 +74,7 @@ public void Dispose() private protected sealed class State : IDisposable { - public ConnectionState? ConnectionState { get; private set; } + private ConnectionState? _connectionState; public CancellationTokenProvider ClosedTokenProvider { get; } = new(); @@ -90,7 +90,7 @@ public void IndicateConnected(ConnectionState connectionState) { lock (ClosedTokenProvider) { - if (ConnectionState != connectionState) + if (_connectionState != connectionState) return; _connectedCompletionSource.TrySetResult(new ConnectionStateResult.Success(connectionState)); @@ -101,7 +101,7 @@ public void IndicateReady(ConnectionState connectionState) { lock (ClosedTokenProvider) { - if (ConnectionState != connectionState) + if (_connectionState != connectionState) return; _readyCompletionSource.TrySetResult(new ConnectionStateResult.Success(connectionState)); @@ -115,10 +115,10 @@ public ConnectingResult TryIndicateConnecting(ConnectionState connectionState) if (ClosedTokenProvider.IsCancellationRequested) return ConnectingResult.Closed; - if (ConnectionState is not null) + if (_connectionState is not null) return ConnectingResult.AlreadyStarted; - ConnectionState = connectionState; + _connectionState = connectionState; } return ConnectingResult.Success; @@ -137,10 +137,10 @@ public void IndicateConnectingFailed(ConnectionState connectionState) { _ = connectionState.TryIndicateDisconnecting(); - if (ConnectionState != connectionState) + if (_connectionState != connectionState) return; - ConnectionState = null; + _connectionState = null; } } @@ -153,14 +153,14 @@ public bool TryIndicateClosing([MaybeNullWhen(false)] out ConnectionState connec _readyCompletionSource.TrySetCanceled(); _connectedCompletionSource.TrySetCanceled(); - var previousState = ConnectionState; + var previousState = _connectionState; if (previousState is null || !previousState.TryIndicateDisconnecting()) { connectionState = null; return false; } - ConnectionState = null; + _connectionState = null; connectionState = previousState; } @@ -171,35 +171,10 @@ public bool TryIndicateDisconnecting(ConnectionState connectionState) { lock (ClosedTokenProvider) { - if (ConnectionState != connectionState || !connectionState.TryIndicateDisconnecting()) + if (_connectionState != connectionState || !connectionState.TryIndicateDisconnecting()) return false; - ConnectionState = null; - - var retry = ConnectionStateResult.Retry.Instance; - _readyCompletionSource.TrySetResult(retry); - _connectedCompletionSource.TrySetResult(retry); - - _readyCompletionSource = new(); - _connectedCompletionSource = new(); - } - - return true; - } - - public bool TryIndicateDisconnecting([MaybeNullWhen(false)] out ConnectionState connectionState) - { - lock (ClosedTokenProvider) - { - var previousState = ConnectionState; - if (previousState is null || !previousState.TryIndicateDisconnecting()) - { - connectionState = null; - return false; - } - - ConnectionState = null; - connectionState = previousState; + _connectionState = null; var retry = ConnectionStateResult.Retry.Instance; _readyCompletionSource.TrySetResult(retry); @@ -216,7 +191,7 @@ public void Dispose() { _readyCompletionSource.TrySetCanceled(); _connectedCompletionSource.TrySetCanceled(); - ConnectionState?.Dispose(); + _connectionState?.Dispose(); ClosedTokenProvider.Dispose(); } } @@ -275,10 +250,10 @@ private async void HandleConnecting() await InvokeEventAsync(Connecting).ConfigureAwait(false); } - private async void HandleConnected(State state) + private async void HandleConnected(State state, ConnectionState connectionState) { OnConnected(); - state.IndicateConnected(state.ConnectionState!); + state.IndicateConnected(connectionState); InvokeLog(LogMessage.Info("Connected")); await InvokeEventAsync(Connect).ConfigureAwait(false); } @@ -315,27 +290,17 @@ private async void HandleClosed() await InvokeEventAsync(Close).ConfigureAwait(false); } - private async void HandleMessageReceived(State state, ReadOnlyMemory data) + private async void HandleMessageReceived(State state, ConnectionState connectionState, ReadOnlyMemory data) { try { - JsonPayload payload; - try - { - payload = CreatePayload(data); - } - catch (Exception ex) - { - InvokeLog(LogMessage.Error(ex)); - await AbortAndReconnectAsync(state).ConfigureAwait(false); - return; - } - - await ProcessPayloadAsync(state, payload).ConfigureAwait(false); + var payload = CreatePayload(data); + await ProcessPayloadAsync(state, connectionState, payload).ConfigureAwait(false); } catch (Exception ex) { InvokeLog(LogMessage.Error(ex)); + await AbortAndReconnectAsync(state, connectionState).ConfigureAwait(false); } } @@ -387,7 +352,7 @@ private protected async Task ConnectAsync(State state, Cancella { HandleConnecting(); await connection.OpenAsync(Uri, cancellationToken).ConfigureAwait(false); - HandleConnected(state); + HandleConnected(state, connectionState); } catch (Exception) { @@ -439,9 +404,8 @@ public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus. } } - private async Task ReadAsync(State state) + private async Task ReadAsync(State state, ConnectionState connectionState) { - var connectionState = state.ConnectionState!; var connection = connectionState.Connection; try { @@ -456,7 +420,7 @@ private async Task ReadAsync(State state) break; writer.Advance(result.Count); - HandleMessageReceived(state, writer.WrittenMemory); + HandleMessageReceived(state, connectionState, writer.WrittenMemory); writer.Clear(); } else @@ -492,9 +456,9 @@ private protected virtual void OnConnected() { } - private protected ValueTask AbortAndReconnectAsync(State state) + private protected ValueTask AbortAndReconnectAsync(State state, ConnectionState connectionState) { - if (!state.TryIndicateDisconnecting(out var connectionState)) + if (!state.TryIndicateDisconnecting(connectionState)) return default; try @@ -700,7 +664,7 @@ private protected async void StartHeartbeating(ConnectionState state, double int private protected virtual JsonPayload CreatePayload(ReadOnlyMemory payload) => JsonSerializer.Deserialize(payload.Span, Serialization.Default.JsonPayload)!; - private protected abstract Task ProcessPayloadAsync(State state, JsonPayload payload); + private protected abstract Task ProcessPayloadAsync(State state, ConnectionState connectionState, JsonPayload payload); private protected async void InvokeLog(LogMessage logMessage) { From 5a665e59f6f1822f172b807d0c615e18dcb82d51 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Wed, 28 Aug 2024 17:40:28 +0200 Subject: [PATCH 20/33] Refactor --- NetCord/Gateway/WebSocketClient.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index d4f2880f3..e4d02a4e9 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -557,10 +557,9 @@ private protected static async ValueTask SendConnectionPayloadAsync(ConnectionSt } catch (TaskCanceledException ex) { - if (disconnectedToken.IsCancellationRequested) - return ex; + cancellationToken.ThrowIfCancellationRequested(); - throw; + return ex; } continue; From 27e2127a100a9b01c52d9bc7ea69efeb977f57a1 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Wed, 28 Aug 2024 17:41:46 +0200 Subject: [PATCH 21/33] Invoke closed on abort when reconnecting and improve Abort method --- NetCord/Gateway/WebSocketClient.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index e4d02a4e9..aa63c6df9 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -444,12 +444,21 @@ public void Abort() if (state is null) return; - if (state.TryIndicateClosing(out var connectionState)) + if (!state.TryIndicateClosing(out var connectionState)) + return; + + try { - state.Dispose(); connectionState.Connection.Abort(); - HandleClosed(); } + catch (Exception ex) + { + InvokeLog(LogMessage.Error(ex)); + } + + connectionState.Dispose(); + state.Dispose(); + HandleClosed(); } private protected virtual void OnConnected() @@ -470,6 +479,9 @@ private protected ValueTask AbortAndReconnectAsync(State state, ConnectionState InvokeLog(LogMessage.Error(ex)); } + connectionState.Dispose(); + HandleClosed(); + return ReconnectAsync(state); } @@ -559,7 +571,7 @@ private protected static async ValueTask SendConnectionPayloadAsync(ConnectionSt { cancellationToken.ThrowIfCancellationRequested(); - return ex; + return ex; } continue; From d59d1d25e3c0b3a22df6e683066897e813d01b68 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Wed, 28 Aug 2024 18:04:01 +0200 Subject: [PATCH 22/33] Normalize naming --- NetCord/Gateway/Voice/VoiceClient.cs | 4 ++-- NetCord/Gateway/WebSocketClient.cs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/NetCord/Gateway/Voice/VoiceClient.cs b/NetCord/Gateway/Voice/VoiceClient.cs index e83ef8472..1320c981e 100644 --- a/NetCord/Gateway/Voice/VoiceClient.cs +++ b/NetCord/Gateway/Voice/VoiceClient.cs @@ -83,11 +83,11 @@ public async Task ResumeAsync(CancellationToken cancellationToken = default) private protected override bool Reconnect(WebSocketCloseStatus? status, string? description) => status is not ((WebSocketCloseStatus)4004 or (WebSocketCloseStatus)4006 or (WebSocketCloseStatus)4009 or (WebSocketCloseStatus)4014); - private protected override ValueTask TryResumeAsync(ConnectionState state, CancellationToken cancellationToken = default) + private protected override ValueTask TryResumeAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) { var serializedPayload = new VoicePayloadProperties(VoiceOpcode.Resume, new(GuildId, SessionId, Token)).Serialize(Serialization.Default.VoicePayloadPropertiesVoiceResumeProperties); _latencyTimer.Start(); - return SendConnectionPayloadAsync(state, serializedPayload, _internalPayloadProperties, cancellationToken); + return SendConnectionPayloadAsync(connectionState, serializedPayload, _internalPayloadProperties, cancellationToken); } private protected override ValueTask HeartbeatAsync(ConnectionState connectionState, CancellationToken cancellationToken = default) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index aa63c6df9..3d07c8897 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -636,11 +636,11 @@ private protected async ValueTask ReconnectAsync(State state) } } - private protected abstract ValueTask TryResumeAsync(ConnectionState state, CancellationToken cancellationToken = default); + private protected abstract ValueTask TryResumeAsync(ConnectionState connectionState, CancellationToken cancellationToken = default); - private protected async void StartHeartbeating(ConnectionState state, double interval) + private protected async void StartHeartbeating(ConnectionState connectionState, double interval) { - var cancellationToken = state.DisconnectedTokenProvider.Token; + var cancellationToken = connectionState.DisconnectedTokenProvider.Token; PeriodicTimer timer; @@ -661,7 +661,7 @@ private protected async void StartHeartbeating(ConnectionState state, double int try { await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false); - await HeartbeatAsync(state, cancellationToken).ConfigureAwait(false); + await HeartbeatAsync(connectionState, cancellationToken).ConfigureAwait(false); } catch { From 7db7c8175ba9e49d462f3e8ef923328b02d68bf1 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 00:00:24 +0200 Subject: [PATCH 23/33] Delete `ReadTask` --- NetCord/Gateway/WebSocketClient.cs | 21 +-------------------- Tests/NetCord.Test/Program.cs | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 3d07c8897..a0aa4db75 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -35,24 +35,8 @@ private protected sealed class ConnectionState(IWebSocketConnection connection, public CancellationTokenProvider DisconnectedTokenProvider { get; } = new(); - public Task ReadTask => _readCompletionSource.Task; - - private readonly TaskCompletionSource _readCompletionSource = new(); - private int _state; - public async void StartReading(State state, Func readAsync) - { - try - { - await readAsync(state, this).ConfigureAwait(false); - } - finally - { - _readCompletionSource.TrySetResult(); - } - } - public bool TryIndicateDisconnecting() { var disconnecting = Interlocked.Exchange(ref _state, 1) is 0; @@ -65,7 +49,6 @@ public bool TryIndicateDisconnecting() public void Dispose() { - _readCompletionSource.TrySetResult(); DisconnectedTokenProvider.Dispose(); RateLimiter.Dispose(); Connection.Dispose(); @@ -361,7 +344,7 @@ private protected async Task ConnectAsync(State state, Cancella throw; } - connectionState.StartReading(state, ReadAsync); + _ = ReadAsync(state, connectionState); return connectionState; } @@ -398,8 +381,6 @@ public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus. return; } - await connectionState.ReadTask.ConfigureAwait(false); - HandleClosed(); } } diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index c0a99b546..a926201c9 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -118,14 +118,24 @@ await _client.UpdatePresenceAsync(new(UserStatusType.Online) Console.WriteLine(i); } - await _client.CloseAsync(); - await _client.StartAsync(); - - await _client.UpdatePresenceAsync(new(UserStatusType.Online) + var task = _client.UpdatePresenceAsync(new(UserStatusType.Online) { Activities = [new($"wzium", UserActivityType.Game)], }); + await Task.Delay(1000); + + await _client.CloseAsync(); + + await task; + + //await _client.StartAsync(); + + //await _client.UpdatePresenceAsync(new(UserStatusType.Online) + //{ + // Activities = [new($"wzium", UserActivityType.Game)], + //}); + await Task.Delay(-1); } From de187d19bc1bc035b9d0beec8ceed34f454c1da6 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 11:44:31 +0200 Subject: [PATCH 24/33] Improve gateway rate limits --- NetCord/Gateway/GatewayRateLimiterProvider.cs | 20 ++++++++- NetCord/Gateway/IRateLimiter.cs | 4 +- NetCord/Gateway/NullRateLimiter.cs | 16 ------- NetCord/Gateway/NullRateLimiterProvider.cs | 19 +++++++- NetCord/Gateway/WebSocketClient.cs | 36 +++++++++++++++- Tests/NetCord.Test/Program.cs | 43 +++++++++++-------- 6 files changed, 100 insertions(+), 38 deletions(-) delete mode 100644 NetCord/Gateway/NullRateLimiter.cs diff --git a/NetCord/Gateway/GatewayRateLimiterProvider.cs b/NetCord/Gateway/GatewayRateLimiterProvider.cs index 6463b566e..bbea17dd5 100644 --- a/NetCord/Gateway/GatewayRateLimiterProvider.cs +++ b/NetCord/Gateway/GatewayRateLimiterProvider.cs @@ -11,7 +11,7 @@ private sealed class GatewayRateLimiter(int limit, long duration) : IRateLimiter private int _remaining = limit; private long _reset; - public ValueTask TryAcquireAsync() + public ValueTask TryAcquireAsync(CancellationToken cancellationToken = default) { var timestamp = Environment.TickCount64; lock (_lock) @@ -34,6 +34,24 @@ public ValueTask TryAcquireAsync() return new(RateLimitAcquisitionResult.NoRateLimit); } + public ValueTask CancelAcquireAsync(long acquisitionTimestamp, CancellationToken cancellationToken = default) + { + var currentTimestamp = Environment.TickCount64; + lock (_lock) + { + var reset = _reset; + var start = reset - duration; + if (acquisitionTimestamp <= reset + && acquisitionTimestamp >= start + && currentTimestamp <= reset + && currentTimestamp >= start + && _remaining < _limit) + _remaining++; + } + + return default; + } + public void Dispose() { } diff --git a/NetCord/Gateway/IRateLimiter.cs b/NetCord/Gateway/IRateLimiter.cs index 7dd7d0ce4..cbd1c3358 100644 --- a/NetCord/Gateway/IRateLimiter.cs +++ b/NetCord/Gateway/IRateLimiter.cs @@ -2,5 +2,7 @@ public interface IRateLimiter : IDisposable { - public ValueTask TryAcquireAsync(); + public ValueTask TryAcquireAsync(CancellationToken cancellationToken = default); + + public ValueTask CancelAcquireAsync(long acquisitionTimestamp, CancellationToken cancellationToken = default); } diff --git a/NetCord/Gateway/NullRateLimiter.cs b/NetCord/Gateway/NullRateLimiter.cs deleted file mode 100644 index 5778f2b6b..000000000 --- a/NetCord/Gateway/NullRateLimiter.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace NetCord.Gateway; - -internal sealed class NullRateLimiter : IRateLimiter -{ - public static NullRateLimiter Instance { get; } = new(); - - private NullRateLimiter() - { - } - - public ValueTask TryAcquireAsync() => new(RateLimitAcquisitionResult.NoRateLimit); - - public void Dispose() - { - } -} diff --git a/NetCord/Gateway/NullRateLimiterProvider.cs b/NetCord/Gateway/NullRateLimiterProvider.cs index 2a545e56c..56f54048b 100644 --- a/NetCord/Gateway/NullRateLimiterProvider.cs +++ b/NetCord/Gateway/NullRateLimiterProvider.cs @@ -1,8 +1,25 @@ namespace NetCord.Gateway; -internal class NullRateLimiterProvider : IRateLimiterProvider +public class NullRateLimiterProvider : IRateLimiterProvider { public static NullRateLimiterProvider Instance { get; } = new(); public IRateLimiter CreateRateLimiter() => NullRateLimiter.Instance; + + private sealed class NullRateLimiter : IRateLimiter + { + public static NullRateLimiter Instance { get; } = new(); + + private NullRateLimiter() + { + } + + public ValueTask TryAcquireAsync(CancellationToken cancellationToken = default) => new(RateLimitAcquisitionResult.NoRateLimit); + + public ValueTask CancelAcquireAsync(long acquisitionTimestamp, CancellationToken cancellationToken = default) => default; + + public void Dispose() + { + } + } } diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index a0aa4db75..b387554df 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -538,7 +538,17 @@ private protected static async ValueTask SendConnectionPayloadAsync(ConnectionSt while (true) { - var result = await rateLimiter.TryAcquireAsync().ConfigureAwait(false); + RateLimitAcquisitionResult result; + try + { + result = await rateLimiter.TryAcquireAsync(linkedToken).ConfigureAwait(false); + } + catch (Exception ex) + { + cancellationToken.ThrowIfCancellationRequested(); + + return ex; + } if (result.RateLimited) { @@ -561,12 +571,34 @@ private protected static async ValueTask SendConnectionPayloadAsync(ConnectionSt ThrowRateLimitTriggered(result.ResetAfter); } + var timestamp = Environment.TickCount64; + try { await connectionState.Connection.SendAsync(buffer, properties.MessageType, properties.MessageFlags, linkedToken).ConfigureAwait(false); } - catch (Exception ex) when (ex is not ArgumentException) + catch (ArgumentException) + { + try + { + await rateLimiter.CancelAcquireAsync(timestamp, default).ConfigureAwait(false); + } + catch + { + } + + throw; + } + catch (Exception ex) { + try + { + await rateLimiter.CancelAcquireAsync(timestamp, default).ConfigureAwait(false); + } + catch + { + } + cancellationToken.ThrowIfCancellationRequested(); return ex; diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index a926201c9..85bb6bc68 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -99,24 +99,33 @@ private static async Task Main() //await Task.WhenAll(_client.CloseAsync(), _client.RequestGuildUsersAsync(new(0)).AsTask()); - try - { - await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); - } - catch (RestException ex) - { - var error = ex.Error; - Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); - } + //try + //{ + // await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); + //} + //catch (RestException ex) + //{ + // var error = ex.Error; + // Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); + //} - for (int i = 0; i < 119; i++) - { - await _client.UpdatePresenceAsync(new(UserStatusType.Online) - { - Activities = [new($"wzium {i}", UserActivityType.Game)], - }); - Console.WriteLine(i); - } + //for (int i = 0; i < 119; i++) + //{ + // await _client.UpdatePresenceAsync(new(UserStatusType.Online) + // { + // Activities = [new($"wzium {i}", UserActivityType.Game)], + // }); + // Console.WriteLine(i); + //} + + //await _client.CloseAsync(); + + //await _client.StartAsync(); + + //await _client.UpdatePresenceAsync(new(UserStatusType.Online) + //{ + // Activities = [new($"wzium", UserActivityType.Game)], + //}); var task = _client.UpdatePresenceAsync(new(UserStatusType.Online) { From 3876cc2ed10c47912e3daced8e870825ce2c097c Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 11:44:41 +0200 Subject: [PATCH 25/33] Normalize rest rate limits --- NetCord/Rest/RateLimits/IRouteRateLimiter.cs | 2 +- .../Rest/RateLimits/NoRateLimitRouteRateLimiter.cs | 2 +- NetCord/Rest/RateLimits/RouteRateLimiter.cs | 11 +++++++++-- NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/NetCord/Rest/RateLimits/IRouteRateLimiter.cs b/NetCord/Rest/RateLimits/IRouteRateLimiter.cs index 89aca046b..6595ffdbb 100644 --- a/NetCord/Rest/RateLimits/IRouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/IRouteRateLimiter.cs @@ -6,7 +6,7 @@ public interface IRouteRateLimiter : IRateLimiter public BucketInfo? BucketInfo { get; } - public ValueTask CancelAcquireAsync(long timestamp); + public ValueTask CancelAcquireAsync(long acquisitionTimestamp); public ValueTask UpdateAsync(RateLimitInfo rateLimitInfo); diff --git a/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs b/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs index 7ee722dd8..2f94a1978 100644 --- a/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/NoRateLimitRouteRateLimiter.cs @@ -13,7 +13,7 @@ public ValueTask TryAcquireAsync() return new(RateLimitAcquisitionResult.NoRateLimit); } - public ValueTask CancelAcquireAsync(long timestamp) + public ValueTask CancelAcquireAsync(long acquisitionTimestamp) { return default; } diff --git a/NetCord/Rest/RateLimits/RouteRateLimiter.cs b/NetCord/Rest/RateLimits/RouteRateLimiter.cs index 372eaaf01..3b53e81f0 100644 --- a/NetCord/Rest/RateLimits/RouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/RouteRateLimiter.cs @@ -37,11 +37,18 @@ public ValueTask TryAcquireAsync() return new(RateLimitAcquisitionResult.NoRateLimit); } - public ValueTask CancelAcquireAsync(long timestamp) + public ValueTask CancelAcquireAsync(long acquisitionTimestamp) { + var currentTimestamp = Environment.TickCount64; lock (_lock) { - if (timestamp - (_reset - _maxResetAfter) >= -50 && _remaining < _limit) + var reset = _reset; + var safeStart = reset - _maxResetAfter - 50; + if (acquisitionTimestamp <= reset + && acquisitionTimestamp >= safeStart + && currentTimestamp <= reset + && currentTimestamp >= safeStart + && _remaining < _limit) _remaining++; } return default; diff --git a/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs b/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs index 8718c6757..5fada5ddd 100644 --- a/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs +++ b/NetCord/Rest/RateLimits/UnknownRouteRateLimiter.cs @@ -22,7 +22,7 @@ public async ValueTask TryAcquireAsync() return RateLimitAcquisitionResult.NoRateLimit; } - public ValueTask CancelAcquireAsync(long timestamp) + public ValueTask CancelAcquireAsync(long acquisitionTimestamp) { _retry = false; _semaphore.Release(); From ae39d41a17a53826737bbb0a6cd3d7adda11f381 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 15:33:49 +0200 Subject: [PATCH 26/33] Cleanup test project --- Tests/NetCord.Test/Program.cs | 55 ----------------------------------- 1 file changed, 55 deletions(-) diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index 85bb6bc68..2e4d5bf9a 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -90,61 +90,6 @@ private static async Task Main() manager.AddService(_userCommandService); await _client.StartAsync(); - - //await _client.CloseAsync(); - - ////await _client.StartAsync(); - - //await _client.RequestGuildUsersAsync(new(0)); - - //await Task.WhenAll(_client.CloseAsync(), _client.RequestGuildUsersAsync(new(0)).AsTask()); - - //try - //{ - // await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); - //} - //catch (RestException ex) - //{ - // var error = ex.Error; - // Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); - //} - - //for (int i = 0; i < 119; i++) - //{ - // await _client.UpdatePresenceAsync(new(UserStatusType.Online) - // { - // Activities = [new($"wzium {i}", UserActivityType.Game)], - // }); - // Console.WriteLine(i); - //} - - //await _client.CloseAsync(); - - //await _client.StartAsync(); - - //await _client.UpdatePresenceAsync(new(UserStatusType.Online) - //{ - // Activities = [new($"wzium", UserActivityType.Game)], - //}); - - var task = _client.UpdatePresenceAsync(new(UserStatusType.Online) - { - Activities = [new($"wzium", UserActivityType.Game)], - }); - - await Task.Delay(1000); - - await _client.CloseAsync(); - - await task; - - //await _client.StartAsync(); - - //await _client.UpdatePresenceAsync(new(UserStatusType.Online) - //{ - // Activities = [new($"wzium", UserActivityType.Game)], - //}); - await Task.Delay(-1); } From a14bd3f2dae784c15da4c873a99aebc71e05bc9e Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 15:35:41 +0200 Subject: [PATCH 27/33] Code cleanup --- Tests/NetCord.Test/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index 2e4d5bf9a..84ee984b1 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -1,6 +1,5 @@ using System.Reflection; using System.Runtime.InteropServices; -using System.Text.Json; using Microsoft.Extensions.DependencyInjection; From a648e8e25ecd2ece48f3520e645360605afc546e Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 18:55:41 +0200 Subject: [PATCH 28/33] Improve send cancellation and remove `WebSocketMessageFlags.BypassReady` as it is not needed --- NetCord/Gateway/WebSocketClient.cs | 6 +++--- NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index b387554df..2dda69365 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -192,7 +192,7 @@ private protected WebSocketClient(IWebSocketClientConfiguration configuration) private protected static readonly WebSocketPayloadProperties _internalPayloadProperties = new() { - MessageFlags = WebSocketMessageFlags.EndOfMessage | WebSocketMessageFlags.BypassReady, + MessageFlags = WebSocketMessageFlags.EndOfMessage, RetryHandling = WebSocketRetryHandling.RetryRateLimit, }; @@ -477,14 +477,14 @@ public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPa if (state is null) ThrowConnectionNotStarted(); - var task = properties.MessageFlags.HasFlag(WebSocketMessageFlags.BypassReady) ? state.ConnectedTask : state.ReadyTask; + var task = state.ReadyTask; ConnectionState connectionState; if (!task.IsCompleted) { if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) { - var result = await task.ConfigureAwait(false); + var result = await task.WaitAsync(cancellationToken).ConfigureAwait(false); if (result is ConnectionStateResult.Success successResult) connectionState = successResult.ConnectionState; else diff --git a/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs index 834afe1b0..cfaa63cfb 100644 --- a/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs +++ b/NetCord/Gateway/WebSockets/WebSocketMessageFlags.cs @@ -6,5 +6,4 @@ public enum WebSocketMessageFlags : byte None = 0, EndOfMessage = 1 << 0, DisableCompression = 1 << 1, - BypassReady = 1 << 7, } From f9b22100bc581ce7d9e8a393e155626cbfdbfac2 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 18:59:52 +0200 Subject: [PATCH 29/33] Fix test --- Tests/NetCord.Test/Program.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Tests/NetCord.Test/Program.cs b/Tests/NetCord.Test/Program.cs index 84ee984b1..f84f3d8f0 100644 --- a/Tests/NetCord.Test/Program.cs +++ b/Tests/NetCord.Test/Program.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; @@ -89,6 +90,15 @@ private static async Task Main() manager.AddService(_userCommandService); await _client.StartAsync(); + try + { + await manager.CreateCommandsAsync(_client.Rest, _client.Id, true); + } + catch (RestException ex) + { + var error = ex.Error; + Console.WriteLine(error is null ? "No error returned." : JsonSerializer.Serialize(error, Discord.SerializerOptions)); + } await Task.Delay(-1); } From defd84d4eb72bc9ff5e8031e106509fa65cbe08c Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 19:08:14 +0200 Subject: [PATCH 30/33] Add rate limiter support for voice client --- NetCord/Gateway/Voice/VoiceClientConfiguration.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs index 8c1ff16ea..67868134b 100644 --- a/NetCord/Gateway/Voice/VoiceClientConfiguration.cs +++ b/NetCord/Gateway/Voice/VoiceClientConfiguration.cs @@ -9,6 +9,7 @@ namespace NetCord.Gateway.Voice; public class VoiceClientConfiguration : IWebSocketClientConfiguration { public IWebSocketConnectionProvider? WebSocketConnectionProvider { get; init; } + public IRateLimiterProvider? RateLimiterProvider { get; init; } public WebSocketPayloadProperties? DefaultPayloadProperties { get; init; } public IUdpSocket? UdpSocket { get; init; } public IReconnectStrategy? ReconnectStrategy { get; init; } @@ -17,6 +18,4 @@ public class VoiceClientConfiguration : IWebSocketClientConfiguration public IVoiceClientCache? Cache { get; init; } public IVoiceEncryption? Encryption { get; init; } public bool RedirectInputStreams { get; init; } - - IRateLimiterProvider? IWebSocketClientConfiguration.RateLimiterProvider => null; } From bd72eccc089002f923fae48335e06921834698a4 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Thu, 29 Aug 2024 19:33:00 +0200 Subject: [PATCH 31/33] Improve disposing --- NetCord/Gateway/WebSocketClient.cs | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 2dda69365..73a0e26e8 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -254,14 +254,14 @@ private async void HandleDisconnected(State state, ConnectionState connectionSta if (reconnect) { - await ReconnectAsync(state).ConfigureAwait(false); connectionState.Dispose(); + await ReconnectAsync(state).ConfigureAwait(false); } else { Interlocked.CompareExchange(ref _state, null, state); - state.Dispose(); connectionState.Dispose(); + state.Dispose(); } await disconnectTask.ConfigureAwait(false); @@ -366,23 +366,27 @@ public async Task CloseAsync(WebSocketCloseStatus status = WebSocketCloseStatus. if (!state.TryIndicateClosing(out var connectionState)) return; - using (state) + var connection = connectionState.Connection; + try + { + await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not ArgumentException) { - var connection = connectionState.Connection; + InvokeLog(LogMessage.Error(ex)); try { - await connection.CloseAsync((int)status, statusDescription, cancellationToken).ConfigureAwait(false); + connection.Abort(); } - catch (Exception ex) when (ex is not ArgumentException) + catch (Exception abortEx) { - InvokeLog(LogMessage.Error(ex)); - connection.Abort(); - HandleClosed(); - return; + InvokeLog(LogMessage.Error(abortEx)); } - - HandleClosed(); } + + connectionState.Dispose(); + state.Dispose(); + HandleClosed(); } private async Task ReadAsync(State state, ConnectionState connectionState) @@ -414,8 +418,6 @@ private async Task ReadAsync(State state, ConnectionState connectionState) if (state.TryIndicateDisconnecting(connectionState)) HandleDisconnected(state, connectionState); - else - connectionState.Dispose(); } public void Abort() From 34a52e204a4eb5a615d30cccb0122d2232ad11e8 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Fri, 30 Aug 2024 18:28:00 +0200 Subject: [PATCH 32/33] Remove connected task --- NetCord/Gateway/WebSocketClient.cs | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 73a0e26e8..4ae8701a2 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -65,21 +65,6 @@ private protected sealed class State : IDisposable private TaskCompletionSource _readyCompletionSource = new(); - public Task ConnectedTask => _connectedCompletionSource.Task; - - private TaskCompletionSource _connectedCompletionSource = new(); - - public void IndicateConnected(ConnectionState connectionState) - { - lock (ClosedTokenProvider) - { - if (_connectionState != connectionState) - return; - - _connectedCompletionSource.TrySetResult(new ConnectionStateResult.Success(connectionState)); - } - } - public void IndicateReady(ConnectionState connectionState) { lock (ClosedTokenProvider) @@ -134,7 +119,6 @@ public bool TryIndicateClosing([MaybeNullWhen(false)] out ConnectionState connec ClosedTokenProvider.Cancel(); _readyCompletionSource.TrySetCanceled(); - _connectedCompletionSource.TrySetCanceled(); var previousState = _connectionState; if (previousState is null || !previousState.TryIndicateDisconnecting()) @@ -159,12 +143,8 @@ public bool TryIndicateDisconnecting(ConnectionState connectionState) _connectionState = null; - var retry = ConnectionStateResult.Retry.Instance; - _readyCompletionSource.TrySetResult(retry); - _connectedCompletionSource.TrySetResult(retry); - + _readyCompletionSource.TrySetResult(ConnectionStateResult.Retry.Instance); _readyCompletionSource = new(); - _connectedCompletionSource = new(); } return true; @@ -173,7 +153,6 @@ public bool TryIndicateDisconnecting(ConnectionState connectionState) public void Dispose() { _readyCompletionSource.TrySetCanceled(); - _connectedCompletionSource.TrySetCanceled(); _connectionState?.Dispose(); ClosedTokenProvider.Dispose(); } @@ -233,10 +212,9 @@ private async void HandleConnecting() await InvokeEventAsync(Connecting).ConfigureAwait(false); } - private async void HandleConnected(State state, ConnectionState connectionState) + private async void HandleConnected() { OnConnected(); - state.IndicateConnected(connectionState); InvokeLog(LogMessage.Info("Connected")); await InvokeEventAsync(Connect).ConfigureAwait(false); } @@ -335,7 +313,7 @@ private protected async Task ConnectAsync(State state, Cancella { HandleConnecting(); await connection.OpenAsync(Uri, cancellationToken).ConfigureAwait(false); - HandleConnected(state, connectionState); + HandleConnected(); } catch (Exception) { From da7e6543e1294931d0b11e00bb8bb4b25d1988d3 Mon Sep 17 00:00:00 2001 From: KubaZ2 Date: Fri, 30 Aug 2024 18:58:58 +0200 Subject: [PATCH 33/33] Refactor payload sending --- NetCord/Gateway/WebSocketClient.cs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/NetCord/Gateway/WebSocketClient.cs b/NetCord/Gateway/WebSocketClient.cs index 4ae8701a2..7e045f7de 100644 --- a/NetCord/Gateway/WebSocketClient.cs +++ b/NetCord/Gateway/WebSocketClient.cs @@ -460,16 +460,10 @@ public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPa var task = state.ReadyTask; ConnectionState connectionState; - if (!task.IsCompleted) + if (task.IsCompleted) { - if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) - { - var result = await task.WaitAsync(cancellationToken).ConfigureAwait(false); - if (result is ConnectionStateResult.Success successResult) - connectionState = successResult.ConnectionState; - else - continue; - } + if (task.Result is ConnectionStateResult.Success successResult) + connectionState = successResult.ConnectionState; else { ThrowConnectionNotStarted(); @@ -478,9 +472,14 @@ public async ValueTask SendPayloadAsync(ReadOnlyMemory buffer, WebSocketPa } else { - var result = task.Result; - if (result is ConnectionStateResult.Success successResult) - connectionState = successResult.ConnectionState; + if (properties.RetryHandling.HasFlag(WebSocketRetryHandling.RetryReconnect)) + { + var result = await task.WaitAsync(cancellationToken).ConfigureAwait(false); + if (result is ConnectionStateResult.Success successResult) + connectionState = successResult.ConnectionState; + else + continue; + } else { ThrowConnectionNotStarted(); @@ -507,7 +506,7 @@ private protected static async ValueTask SendConnectionPayloadAsync(ConnectionSt ThrowConnectionNotStarted(exception); } - private protected static async ValueTask TrySendConnectionPayloadAsync(ConnectionState connectionState, ReadOnlyMemory buffer, WebSocketPayloadProperties properties, CancellationToken cancellationToken = default) + private static async ValueTask TrySendConnectionPayloadAsync(ConnectionState connectionState, ReadOnlyMemory buffer, WebSocketPayloadProperties properties, CancellationToken cancellationToken = default) { var rateLimiter = connectionState.RateLimiter;