diff --git a/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs b/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs index 3c7a0ba..a42b6e4 100644 --- a/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs +++ b/src/StevesBot.Worker/Discord/DiscordGatewayClient.cs @@ -14,6 +14,7 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient private readonly ILogger _logger; private readonly IDiscordRestClient _discordRestClient; private readonly TimeProvider _timeProvider; + private readonly IServiceScopeFactory _serviceScopeFactory; private readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -24,6 +25,7 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient }, }; private readonly AsyncLock _lock = new(); + private readonly Dictionary> _eventHandlers = []; private string _gatewayUrl = string.Empty; private IWebSocket? _webSocket; @@ -45,7 +47,8 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient IWebSocketFactory webSocketFactory, ILogger logger, IDiscordRestClient discordRestClient, - TimeProvider timeProvider + TimeProvider timeProvider, + IServiceScopeFactory serviceScopeFactory ) { _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -53,6 +56,7 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _discordRestClient = discordRestClient ?? throw new ArgumentNullException(nameof(discordRestClient)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory)); } public async Task ConnectAsync(CancellationToken cancellationToken) @@ -75,11 +79,54 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient public async Task DisconnectAsync(CancellationToken cancellationToken) { + await SendIdleStatusAsync(cancellationToken); await CancelReceiveMessagesTaskAsync(cancellationToken); await CancelHeartbeatTaskAsync(cancellationToken); await CloseIfOpenAsync(WebSocketCloseStatus.NormalClosure, "Disconnecting", cancellationToken); } + public void On(string eventName, Func handler) + { + if (DiscordEventTypes.IsValidEvent(eventName) is false) + { + throw new ArgumentException($"Invalid event name: {eventName}", nameof(eventName)); + } + + ArgumentNullException.ThrowIfNull(handler); + + if (_eventHandlers.ContainsKey(eventName) is false) + { + _eventHandlers[eventName] = handler; + return; + } + + _eventHandlers[eventName] += handler; + } + + public void Off(string eventName, Func handler) + { + if (DiscordEventTypes.IsValidEvent(eventName) is false) + { + throw new ArgumentException($"Invalid event name: {eventName}", nameof(eventName)); + } + + ArgumentNullException.ThrowIfNull(handler); + + if (_eventHandlers.TryGetValue(eventName, out var handlers)) + { + handlers -= handler; + + if (handlers is null) + { + _eventHandlers.Remove(eventName); + } + else + { + _eventHandlers[eventName] = handlers; + } + } + } + private async Task StartReceiveMessagesAsync(CancellationToken cancellationToken) { await CancelReceiveMessagesTaskAsync(cancellationToken); @@ -204,6 +251,7 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient break; case DispatchDiscordEvent de: await SetDispatchSequenceAsync(e.Sequence, cancellationToken); + var eventType = de.Type ?? "Unknown"; switch (de) { @@ -213,9 +261,30 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient _logger.LogInformation("Ready event received"); break; default: - _logger.LogInformation("Received dispatch event: {Event}", de.Type ?? "Unknown"); + _logger.LogInformation("Received dispatch event: {Event}", eventType); break; } + + if (_eventHandlers.TryGetValue(eventType, out var handler)) + { + try + { + await using var scope = _serviceScopeFactory.CreateAsyncScope(); + // TODO: Pass cancellation token to handler + await handler(de, scope.ServiceProvider); + } +# pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) + { + _logger.LogError(ex, "Error handling event: {Event}", eventType); + } +# pragma warning restore CA1031 // Do not catch general exception types + } + else + { + _logger.LogInformation("No handler for event: {Event}", eventType); + } + break; case ReconnectDiscordEvent: _logger.LogInformation("Reconnect event received"); @@ -406,6 +475,23 @@ internal sealed class DiscordGatewayClient : IDiscordGatewayClient await SendJsonAsync(identify, cancellationToken); } + private async Task SendIdleStatusAsync(CancellationToken cancellationToken) + { + var presence = new UpdatePresenceDiscordEvent( + _timeProvider.GetUtcNow().Millisecond, + [new Activity + { + Name = "Taking a break. Stevan's got this.", + Type = ActivityType.Custom, + State = "Taking a break. Stevan's got this.", + }], + PresenceStatus.Idle, + true + ); + + await SendJsonAsync(presence, cancellationToken); + } + private async Task SendHeartbeatAsync(CancellationToken cancellationToken) { var heartbeat = new HeartbeatDiscordEvent(_lastSequence); diff --git a/src/StevesBot.Worker/Discord/Events/DiscordEventTypes.cs b/src/StevesBot.Worker/Discord/Events/DiscordEventTypes.cs index 0c64469..a6ab8c2 100644 --- a/src/StevesBot.Worker/Discord/Events/DiscordEventTypes.cs +++ b/src/StevesBot.Worker/Discord/Events/DiscordEventTypes.cs @@ -3,4 +3,14 @@ namespace StevesBot.Worker.Discord.Events; internal static class DiscordEventTypes { public const string Ready = "READY"; + + public static bool IsValidEvent(string eventName) + { + if (string.IsNullOrEmpty(eventName)) + { + return false; + } + + return eventName is Ready; + } } \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Events/UpdatePresenceDiscordEvent.cs b/src/StevesBot.Worker/Discord/Events/UpdatePresenceDiscordEvent.cs index 77d2abb..3170b1f 100644 --- a/src/StevesBot.Worker/Discord/Events/UpdatePresenceDiscordEvent.cs +++ b/src/StevesBot.Worker/Discord/Events/UpdatePresenceDiscordEvent.cs @@ -36,6 +36,7 @@ internal sealed record UpdatePresenceData internal static class PresenceStatus { public const string Online = "online"; + public const string Idle = "idle"; } internal sealed record Activity diff --git a/src/StevesBot.Worker/Discord/IDiscordGatewayClient.cs b/src/StevesBot.Worker/Discord/IDiscordGatewayClient.cs index 8af1b65..2127ac0 100644 --- a/src/StevesBot.Worker/Discord/IDiscordGatewayClient.cs +++ b/src/StevesBot.Worker/Discord/IDiscordGatewayClient.cs @@ -4,4 +4,6 @@ internal interface IDiscordGatewayClient : IDisposable { Task ConnectAsync(CancellationToken cancellationToken); Task DisconnectAsync(CancellationToken cancellationToken); + void On(string eventName, Func handler); + void Off(string eventName, Func handler); } \ No newline at end of file diff --git a/src/StevesBot.Worker/Program.cs b/src/StevesBot.Worker/Program.cs index 826ff01..054364d 100644 --- a/src/StevesBot.Worker/Program.cs +++ b/src/StevesBot.Worker/Program.cs @@ -25,7 +25,11 @@ builder.Services }) .AddStandardResilienceHandler(); +// TODO: Create an extension method that allows adding +// the discord gateway client and allows me to configure +// event handlers builder.Services.AddSingleton(); + builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/src/StevesBot.Worker/Worker.cs b/src/StevesBot.Worker/Worker.cs index cb3496b..0b07e99 100644 --- a/src/StevesBot.Worker/Worker.cs +++ b/src/StevesBot.Worker/Worker.cs @@ -15,6 +15,25 @@ internal class Worker : IHostedService public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Connecting Discord Gateway Client"); + + // TODO: Provide access to the gateway client + // in delegate...don't try to resolve it from the service provider + // TODO: Provide .On and .Off method overloads to allow + // caller to not need to use discard for unused parameters + _discordGatewayClient.On(DiscordEventTypes.Ready, static (discordEvent, sp) => + { + var logger = sp.GetRequiredService>(); + logger.LogInformation("Ready handler 1"); + return Task.CompletedTask; + }); + + _discordGatewayClient.On(DiscordEventTypes.Ready, static (discordEvent, sp) => + { + var logger = sp.GetRequiredService>(); + logger.LogInformation("Ready handler 2"); + return Task.CompletedTask; + }); + return _discordGatewayClient.ConnectAsync(cancellationToken); }