diff --git a/Discord.Addons.Hosting/Discord.Addons.Hosting.csproj b/Discord.Addons.Hosting/Discord.Addons.Hosting.csproj index 9c71fa7..1640ec4 100644 --- a/Discord.Addons.Hosting/Discord.Addons.Hosting.csproj +++ b/Discord.Addons.Hosting/Discord.Addons.Hosting.csproj @@ -3,11 +3,11 @@ netstandard2.0 Discord.Addons.Hosting - 1.0.2 + 1.1.0 Hawxy - Discord.Net hosting with IHostedService and Microsoft.Extensions.Hosting + Simplifying Discord.Net hosting with .NET Generic Host (Microsoft.Extensions.Hosting) true - https://github.com/Hawxy/Discord.Addons.Hosting/blob/azure-pipelines/LICENSE + https://github.com/Hawxy/Discord.Addons.Hosting/blob/master/LICENSE https://github.com/Hawxy/Discord.Addons.Hosting https://github.com/Hawxy/Discord.Addons.Hosting git diff --git a/Discord.Addons.Hosting/DiscordClientHandler.cs b/Discord.Addons.Hosting/DiscordClientHandler.cs index 349a967..1609d22 100644 --- a/Discord.Addons.Hosting/DiscordClientHandler.cs +++ b/Discord.Addons.Hosting/DiscordClientHandler.cs @@ -22,9 +22,9 @@ namespace Discord.Addons.Hosting { /// /// Simple handler to manage client creation - /// The type of Discord.Net client. Type must inherit from + /// The type of Discord.Net client. Type must be or inherit from /// - public class DiscordClientHandler where T: BaseSocketClient, new() + public class DiscordClientHandler where T: DiscordSocketClient { private T _client; @@ -45,11 +45,11 @@ public void AddDiscordClient(T client) /// /// The Discord client configuration object. Ensure the type is compatible with the client type /// Thrown if client initialized more than once - public void UseDiscordConfiguration(Y config) where Y : DiscordConfig + public void UseDiscordConfiguration(Y config) where Y : DiscordSocketConfig { if(_client != null) throw new InvalidOperationException("Client can only be initialized once!"); - //has performance issues, but does it matter if it's only called once? + _client = (T)Activator.CreateInstance(typeof(T), config); } diff --git a/Discord.Addons.Hosting/DiscordHostBuilderExtensions.cs b/Discord.Addons.Hosting/DiscordHostBuilderExtensions.cs index 35d0d42..3b19bc5 100644 --- a/Discord.Addons.Hosting/DiscordHostBuilderExtensions.cs +++ b/Discord.Addons.Hosting/DiscordHostBuilderExtensions.cs @@ -35,13 +35,13 @@ public static class DiscordHostBuilderExtensions /// /// A is supplied so that the configuration and service provider can be used. /// - /// The type of Discord.Net client. Type must inherit from + /// The type of Discord.Net client. Type must be or inherit from /// The host builder to configure. /// The delegate for configuring the that will be used to construct the discord client. /// The (generic) host builder. /// Thrown if is null /// Thrown client is already added or logged in - public static IHostBuilder ConfigureDiscordClient(this IHostBuilder builder, Action> config) where T: BaseSocketClient, new() + public static IHostBuilder ConfigureDiscordClient(this IHostBuilder builder, Action> config) where T: DiscordSocketClient, new() { if(config == null) throw new ArgumentNullException(nameof(config)); @@ -62,8 +62,10 @@ public static class DiscordHostBuilderExtensions throw new InvalidOperationException("Client logged in before host startup! Make sure you aren't calling LoginAsync manually"); collection.AddSingleton(client); + if(typeof(T) != typeof(DiscordSocketClient)) + collection.AddSingleton(x => x.GetRequiredService()); collection.AddSingleton(); - collection.AddHostedService>(); + collection.AddHostedService(); }); return builder; @@ -109,8 +111,7 @@ public static IHostBuilder UseCommandService(this IHostBuilder builder, Action : IHostedService, IDisposable where T: BaseSocketClient, new() + internal class DiscordHostedService : IHostedService, IDisposable { - private readonly ILogger> _logger; - private readonly T _client; + private readonly ILogger _logger; + private readonly DiscordSocketClient _client; private readonly IConfiguration _config; - public DiscordHostedService(ILogger> logger, T client, IConfiguration config, IServiceProvider services) + public DiscordHostedService(ILogger logger, DiscordSocketClient client, IConfiguration config, IServiceProvider services) { _logger = logger; _client = client; @@ -42,12 +42,18 @@ public DiscordHostedService(ILogger> logger, T client, I var adapter = services.GetRequiredService(); //workaround for correct logging category adapter.UseLogger(logger); + + //In cases where the constructor is called multiple times + client.Log -= adapter.Log; client.Log += adapter.Log; var cs = services.GetService(); if (cs != null) + { + cs.Log -= adapter.Log; cs.Log += adapter.Log; + } + } - public async Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Discord.Net hosted service is starting"); @@ -64,7 +70,7 @@ public async Task StopAsync(CancellationToken cancellationToken) public void Dispose() { _logger.LogInformation("Disposing Discord.Net hosted service"); - _client.Dispose(); + _client.Dispose(); } } } diff --git a/Discord.Addons.Hosting/LogAdapter.cs b/Discord.Addons.Hosting/LogAdapter.cs index da48245..bcd2220 100644 --- a/Discord.Addons.Hosting/LogAdapter.cs +++ b/Discord.Addons.Hosting/LogAdapter.cs @@ -38,6 +38,6 @@ private string DefaultFormatter(LogMessage message, Exception _) => $"{message.Source}: {message.Exception?.ToString() ?? message.Message}"; private static LogLevel GetLogLevel(LogSeverity severity) - => (LogLevel) (Math.Abs((int) severity - 5)); + => (LogLevel) Math.Abs((int) severity - 5); } } \ No newline at end of file diff --git a/Discord.Addons.Hosting/Reliability/ReliableDiscordHost.cs b/Discord.Addons.Hosting/Reliability/ReliableDiscordHost.cs new file mode 100644 index 0000000..22896f4 --- /dev/null +++ b/Discord.Addons.Hosting/Reliability/ReliableDiscordHost.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +// Based on ReliabilityService by Foxbot +namespace Discord.Addons.Hosting.Reliability +{ + internal class ReliableDiscordHost : IDisposable + { + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(30); + + private readonly DiscordSocketClient _discord; + private readonly ILogger _logger; + private readonly IHost _host; + private CancellationTokenSource _cts; + + public ReliableDiscordHost(DiscordSocketClient discord, ILogger logger, IHost host) + { + _cts = new CancellationTokenSource(); + _discord = discord; + _logger = logger; + _host = host; + _logger.LogInformation("Using Discord.Net Reliability service - Host will attempt to restart after a 30 second disconnect"); + + _discord.Connected += ConnectedAsync; + _discord.Disconnected += DisconnectedAsync; + } + + private Task ConnectedAsync() + { + _logger.LogDebug("Discord client reconnected, resetting cancel token..."); + _cts.Cancel(); + _cts = new CancellationTokenSource(); + _logger.LogDebug("Discord client reconnected, cancel token reset."); + + return Task.CompletedTask; + } + + private Task DisconnectedAsync(Exception _e) + { + _logger.LogInformation("Discord client disconnected, starting timeout task..."); + _ = Task.Delay(_timeout, _cts.Token).ContinueWith(async _ => + { + _logger.LogDebug("Timeout expired, continuing to check client state..."); + await CheckStateAsync(); + }); + + return Task.CompletedTask; + } + + private async Task CheckStateAsync() + { + // Client reconnected, no need to reset + if (_discord.ConnectionState == ConnectionState.Connected) + { + _logger.LogInformation("Discord client recovered"); + return; + } + + _logger.LogCritical("Client did not reconnect in time, restarting host"); + await _host.StopAsync(); + await _host.StartAsync(); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + _logger.LogInformation("Disposing Reliability Service"); + _discord.Connected -= ConnectedAsync; + _discord.Disconnected -= DisconnectedAsync; + _cts?.Cancel(); + _cts?.Dispose(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + ~ReliableDiscordHost() + { + Dispose(false); + } + } +} \ No newline at end of file diff --git a/Discord.Addons.Hosting/Reliability/ReliableHostExtensions.cs b/Discord.Addons.Hosting/Reliability/ReliableHostExtensions.cs new file mode 100644 index 0000000..0d36f83 --- /dev/null +++ b/Discord.Addons.Hosting/Reliability/ReliableHostExtensions.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Discord.Addons.Hosting.Reliability +{ + /// + /// Extends with Discord.Net Reliability options. + /// + public static class ReliableHostExtensions + { + private static ReliableDiscordHost _reliable; + private static CancellationTokenSource _cts; + + /// + /// Adds the Reliability Service and Runs the host. This function will only return if is called elsewhere. Do not use in combination with + /// + /// The host to configure. + public static async Task RunReliablyAsync(this IHost host) + { + host.WithReliability(); + await host.StartAsync(); + _cts = new CancellationTokenSource(); + + AppDomain.CurrentDomain.ProcessExit += (sender, eventArgs) => + { + _ = host.StopReliablyAsync(); + }; + Console.CancelKeyPress += (sender, e) => { + e.Cancel = true; + _ = host.StopReliablyAsync(); + }; + + await Task.Delay(-1, _cts.Token).ContinueWith(_ => { }); + } + + /// + /// FOR ADVANCED USE ONLY: Directly adds the reliability service to the host. This may result in unexpected behaviour. For most situations you should use instead + /// + /// The host to configure. + internal static IHost WithReliability(this IHost host) + { + if(_reliable != null) + throw new InvalidOperationException("Cannot add Reliability Host, it already exists!"); + + var discord = host.Services.GetRequiredService(); + var logger = host.Services.GetRequiredService>(); + _reliable = new ReliableDiscordHost(discord, logger, host); + + return host; + } + + /// + /// Disposes the reliability service and stops the host. For use when is used to start the host. + /// + /// The host to configure. + public static async Task StopReliablyAsync(this IHost host) + { + if (_reliable == null) + throw new InvalidOperationException("Reliable host is null. Shutdown the host normally with StopAsync instead."); + _reliable.Dispose(); + _reliable = null; + await host.StopAsync(); + _cts.Cancel(); + _cts.Dispose(); + } + } +} diff --git a/README.md b/README.md index 963f306..66916bb 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![NuGet](https://img.shields.io/nuget/v/Discord.Addons.Hosting.svg?style=flat-square)](https://www.nuget.org/packages/Discord.Addons.Hosting) [Discord.Net](https://github.com/RogueException/Discord.Net) hosting with [Microsoft.Extensions.Hosting](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host). -This package provides extensions to IHostBuilder that will run a Discord.Net socket/sharded client as a IHostedService. +This package primarily provides extensions to a .NET Generic Host (IHostBuilder) that will run a Discord.Net socket/sharded client as a controllable IHostedService. This simplifies initial bot creation and moves the usual boilerplate to a convenient builder pattern. Discord.Net 2.0 build 1000 or later is required. @@ -39,4 +39,29 @@ using (host) } ``` -See [samples](https://github.com/Hawxy/Discord.Addons.Hosting/tree/master/Samples) for working examples +### Basic Usage + +1. Create a .NET Core application (or retrofit your existing one) +2. Add the following NuGet packages (at the absolute minimum): + + ```Discord.Addons.Hosting``` + ```Microsoft.Extensions.Hosting``` + ```Microsoft.Extensions.Configuration.Json``` + +3. Create and start your application using a HostBuilder as shown in the [examples](https://github.com/Hawxy/Discord.Addons.Hosting/tree/master/Samples) + +### Serilog + +Serilog should be added to the host with ```Serilog.Extensions.Hosting```. + +See the Serilog [example](https://github.com/Hawxy/Discord.Addons.Hosting/tree/master/Samples/SampleBotSerilog) for usage + +### Reliability + +Discord.Net can occasionally fail to reconnect after an extended outage. This library provides a basic solution that will automatically attempt to restart the host on a failure. Please note that this functionality is experimental and does not guarantee that the client will *always* recover. + +To use the reliability extensions, start the host with ```await host.RunReliablyAsync()```. + +To shutdown the host, it's recommended to add a shutdown command to your bot and call ```host.StopReliablyAsync()```. + +This behaviour is similar to the usage of ```RunAsync()``` and ```StopAsync()``` diff --git a/Samples/SampleBotSerilog/Program.cs b/Samples/SampleBotSerilog/Program.cs index 12cd574..5b098bf 100644 --- a/Samples/SampleBotSerilog/Program.cs +++ b/Samples/SampleBotSerilog/Program.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Discord; using Discord.Addons.Hosting; +using Discord.Addons.Hosting.Reliability; using Discord.Commands; using Discord.WebSocket; using Microsoft.Extensions.Configuration; @@ -14,7 +15,7 @@ namespace SampleBotSerilog { class Program { - static async Task Main(string[] args) + static async Task Main() { //Log is available everywhere, useful for places where it isn't practical to use ILogger injection Log.Logger = new LoggerConfiguration() @@ -61,7 +62,8 @@ static async Task Main(string[] args) .ConfigureServices((context, services) => { services.AddSingleton(); - }); + }) + .UseConsoleLifetime(); //Start and stop just by hitting enter //See https://github.com/aspnet/Hosting/tree/master/samples/GenericHostSample for other control patterns @@ -69,6 +71,7 @@ static async Task Main(string[] args) using (host) { await host.Services.GetRequiredService().InitializeAsync(); + while (true) { Log.Information("Starting!"); diff --git a/Samples/SampleBotSerilog/PublicModule.cs b/Samples/SampleBotSerilog/PublicModule.cs index 8f7f5a7..b621344 100644 --- a/Samples/SampleBotSerilog/PublicModule.cs +++ b/Samples/SampleBotSerilog/PublicModule.cs @@ -1,17 +1,19 @@ using System; -using System.Collections.Generic; -using System.Text; using System.Threading.Tasks; using Discord; +using Discord.Addons.Hosting.Reliability; using Discord.Commands; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace SampleBotSerilog { public class PublicModule : ModuleBase { - //will be injected + //Will be injected public ILogger _logger { get; set; } + //You can inject the host too. This is useful if you want to shutdown the host via a command, but be careful with it. + public IHost _host { get; set; } [Command("ping")] [Alias("pong", "hello")] @@ -21,6 +23,15 @@ public async Task PingAsync() await ReplyAsync("pong!"); } + [Command("shutdown")] + public async Task Stop() + { + //Don't do this if you're using the reliability extension, as it'll just restart the bot. + _ = _host.StopAsync(); + //Instead do this + //_ = _host.StopReliablyAsync(); + } + [Command("log")] public async Task TestLogs() { diff --git a/Samples/SampleBotSimple/Program.cs b/Samples/SampleBotSimple/Program.cs index d00dfcb..cd8d2a1 100644 --- a/Samples/SampleBotSimple/Program.cs +++ b/Samples/SampleBotSimple/Program.cs @@ -1,7 +1,8 @@ -using System.IO; +using System; +using System.IO; using System.Threading.Tasks; -using Discord; using Discord.Addons.Hosting; +using Discord.Addons.Hosting.Reliability; using Discord.Commands; using Discord.WebSocket; using Microsoft.Extensions.Configuration; @@ -14,7 +15,7 @@ namespace SampleBotSimple class Program { //Requires C# 7.1 or later - static async Task Main(string[] args) + static async Task Main() { var builder = new HostBuilder() .ConfigureAppConfiguration(x => @@ -55,14 +56,18 @@ static async Task Main(string[] args) //Add any other services here services.AddSingleton(); }) + //This isn't needed if using the Reliability extension .UseConsoleLifetime(); + - //Fire and forget. Will run until console is closed. var host = builder.Build(); using (host) { await host.Services.GetRequiredService().InitializeAsync(); + //Fire and forget. Will run until console is closed or the service is stopped. Basically the same as normally running the bot. await host.RunAsync(); + //If you want the host to attempt a restart due to a client reconnect deadlock, use the Reliability extension. + //await host.RunReliablyAsync(); } } diff --git a/Samples/SampleBotSimple/PublicModule.cs b/Samples/SampleBotSimple/PublicModule.cs index d852a3a..e7945b6 100644 --- a/Samples/SampleBotSimple/PublicModule.cs +++ b/Samples/SampleBotSimple/PublicModule.cs @@ -1,15 +1,19 @@ using System; using System.Threading.Tasks; using Discord; +using Discord.Addons.Hosting.Reliability; using Discord.Commands; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace SampleBotSimple { public class PublicModule : ModuleBase { - //will be injected + //Will be injected public ILogger _logger { get; set; } + //You can inject the host too. This is useful if you want to shutdown the host via a command, but be careful with it. + public IHost _host { get; set; } [Command("ping")] [Alias("pong", "hello")] @@ -19,6 +23,15 @@ public async Task PingAsync() await ReplyAsync("pong!"); } + [Command("shutdown")] + public async Task Stop() + { + //Don't do this if you're using the reliability extension, as it'll just restart the bot. + _ = _host.StopAsync(); + //Instead do this + //_ = _host.StopReliablyAsync(); + } + [Command("log")] public async Task TestLogs() {