diff --git a/DisCatSharp.Configuration/ConfigurationExtensions.cs b/DisCatSharp.Configuration/ConfigurationExtensions.cs index 3c836ea2f..c7ece00b6 100644 --- a/DisCatSharp.Configuration/ConfigurationExtensions.cs +++ b/DisCatSharp.Configuration/ConfigurationExtensions.cs @@ -1,279 +1,287 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections; using System.Linq; using DisCatSharp.Configuration.Models; using Microsoft.Extensions.Configuration; namespace DisCatSharp.Configuration { /// /// The configuration extensions. /// internal static class ConfigurationExtensions { /// /// The factory error message. /// private const string FactoryErrorMessage = "Require a function which provides a default entity to work with"; /// /// The default root lib. /// public const string DefaultRootLib = "DisCatSharp"; /// /// The config suffix. /// private const string ConfigSuffix = "Configuration"; /// /// Easily piece together paths that will work within /// /// (not used - only for adding context based functionality) /// The strings to piece together /// Strings joined together via ':' public static string ConfigPath(this IConfiguration config, params string[] values) => string.Join(":", values); /// /// Skims over the configuration section and only overrides values that are explicitly defined within the config /// /// Instance of config /// Section which contains values for private static void HydrateInstance(ref object config, ConfigSection section) { var props = config.GetType().GetProperties(); foreach (var prop in props) { // Must have a set method for this to work, otherwise continue on if (prop.SetMethod == null) continue; var entry = section.GetValue(prop.Name); object? value = null; if (typeof(string) == prop.PropertyType) { // We do NOT want to override value if nothing was provided if(!string.IsNullOrEmpty(entry)) prop.SetValue(config, entry); continue; } // We need to address collections a bit differently // They can come in the form of "root:section:name" with a string representation OR // "root:section:name:0" <--- this is not detectable when checking the above path if (typeof(IEnumerable).IsAssignableFrom(prop.PropertyType)) { value = string.IsNullOrEmpty(section.GetValue(prop.Name)) ? section.Config .GetSection(section.GetPath(prop.Name)).Get(prop.PropertyType) : Newtonsoft.Json.JsonConvert.DeserializeObject(entry, prop.PropertyType); if (value == null) continue; prop.SetValue(config, value); } // From this point onward we require the 'entry' value to have something useful if (string.IsNullOrEmpty(entry)) continue; try { // Primitive types are simple to convert if (prop.PropertyType.IsPrimitive) value = Convert.ChangeType(entry, prop.PropertyType); else { // The following types require a different approach if (prop.PropertyType.IsEnum) value = Enum.Parse(prop.PropertyType, entry); else if (typeof(TimeSpan) == prop.PropertyType) value = TimeSpan.Parse(entry); else if (typeof(DateTime) == prop.PropertyType) value = DateTime.Parse(entry); else if (typeof(DateTimeOffset) == prop.PropertyType) value = DateTimeOffset.Parse(entry); } // Update value within our config instance prop.SetValue(config, value); } catch (Exception ex) { Console.Error.WriteLine( $"Unable to convert value of '{entry}' to type '{prop.PropertyType.Name}' for prop '{prop.Name}' in config '{config.GetType().Name}'\n\t\t{ex.Message}"); } } } /// /// Instantiate an entity using then walk through the specified /// and translate user-defined config values to the instantiated instance from /// /// Section containing values for targeted config /// Function which generates a default entity /// Hydrated instance of an entity which contains user-defined values (if any) /// When is null public static object ExtractConfig(this ConfigSection section, Func factory) { if (factory == null) throw new ArgumentNullException(nameof(factory),FactoryErrorMessage); // Create default instance var config = factory(); HydrateInstance(ref config, section); return config; } /// /// Instantiate an entity using then walk through the specified /// in . Translate user-defined config values to the instantiated instance from /// /// Loaded App Configuration /// Name of section to load /// Function which creates a default entity to work with /// (Optional) Used when section is nested within another. Default value is /// Hydrated instance of an entity which contains user-defined values (if any) /// When is null public static object ExtractConfig(this IConfiguration config, string sectionName, Func factory, string? rootSectionName = DefaultRootLib) { if (factory == null) throw new ArgumentNullException(nameof(factory), FactoryErrorMessage); // create default instance var instance = factory(); HydrateInstance(ref instance, new ConfigSection(ref config, sectionName, rootSectionName)); return instance; } /// /// Instantiate a new instance of , then walk through the specified /// in . Translate user-defined config values to the instance. /// /// Loaded App Configuration /// Name of section to load /// (Optional) Used when section is nested with another. Default value is /// Type of instance that represents /// Hydrated instance of which contains the user-defined values (if any). public static TConfig ExtractConfig(this IConfiguration config, string sectionName, string? rootSectionName = DefaultRootLib) where TConfig : new() { // Default values should hopefully be provided from the constructor object configInstance = new TConfig(); HydrateInstance(ref configInstance, new ConfigSection(ref config, sectionName, rootSectionName)); return (TConfig) configInstance; } /// /// Determines if contains a particular section/object (not value) /// /// /// /// { /// "Discord": { // this is a section/object /// /// }, /// "Value": "something" // this is not a section/object /// } /// /// /// /// /// True if section exists, otherwise false public static bool HasSection(this IConfiguration config, params string[] values) { if (!values.Any()) return false; if (values.Length == 1) return config.GetChildren().Any(x => x.Key == values[0]); if (config.GetChildren().All(x => x.Key != values[0])) return false; var current = config.GetSection(values[0]); for (var i = 1; i < values.Length - 1; i++) { if (current.GetChildren().All(x => x.Key != values[i])) return false; current = current.GetSection(values[i]); } return current.GetChildren().Any(x=>x.Key == values[^1]); } /// /// Instantiates an instance of , then consumes any custom /// configuration from user/developer from .
/// View remarks for more info ///
/// /// This is an example of how your JSON structure should look if you wish /// to override one or more of the default values from /// /// { /// "DisCatSharp": { /// "Discord": { } /// } /// } /// ///
/// Alternatively, you can use the type name itself /// /// { /// "DisCatSharp": { /// "DiscordConfiguration": { } /// } /// } /// + /// + /// { + /// "botSectionName": { + /// "DiscordConfiguration": { } + /// } + /// } + /// ///
/// + /// /// Instance of - public static DiscordClient BuildClient(this IConfiguration config) + public static DiscordClient BuildClient(this IConfiguration config, string botSectionName = DefaultRootLib) { - var section = config.HasSection(DefaultRootLib, "Discord") + var section = config.HasSection(botSectionName, "Discord") ? "Discord" - : config.HasSection(DefaultRootLib, $"Discord{ConfigSuffix}") + : config.HasSection(botSectionName, $"Discord{ConfigSuffix}") ? $"Discord:{ConfigSuffix}" : null; return string.IsNullOrEmpty(section) ? new DiscordClient(new()) - : new DiscordClient(config.ExtractConfig(section)); + : new DiscordClient(config.ExtractConfig(section, botSectionName)); } } } diff --git a/DisCatSharp.Hosting.Tests/DisCatSharp.Hosting.Tests.csproj b/DisCatSharp.Hosting.Tests/DisCatSharp.Hosting.Tests.csproj index 0b717fab1..e529ad0bd 100644 --- a/DisCatSharp.Hosting.Tests/DisCatSharp.Hosting.Tests.csproj +++ b/DisCatSharp.Hosting.Tests/DisCatSharp.Hosting.Tests.csproj @@ -1,39 +1,45 @@ net5.0 enable false false runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive all Always + + Always + + + Always + diff --git a/DisCatSharp.Hosting.Tests/HostTests.cs b/DisCatSharp.Hosting.Tests/HostTests.cs index df546cb3f..b565873c7 100644 --- a/DisCatSharp.Hosting.Tests/HostTests.cs +++ b/DisCatSharp.Hosting.Tests/HostTests.cs @@ -1,143 +1,207 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Generic; using DisCatSharp.Interactivity; using DisCatSharp.Lavalink; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Xunit; namespace DisCatSharp.Hosting.Tests { public class Bot : DiscordHostedService { - public Bot(IConfiguration config, ILogger logger, IServiceProvider provider) : base(config, logger, provider) + public Bot(IConfiguration config, ILogger logger, IServiceProvider provider) : base(config, logger, provider) + { + } + } + + public class MyCustomBot : DiscordHostedService + { + public MyCustomBot(IConfiguration config, ILogger logger, IServiceProvider provider) : base(config, logger, provider, "MyCustomBot") { } } public class HostTests { private Dictionary DefaultDiscord() => new() { { "DisCatSharp:Discord:Token", "1234567890" }, { "DisCatSharp:Discord:TokenType", "Bot" }, { "DisCatSharp:Discord:MinimumLogLevel", "Information" }, { "DisCatSharp:Discord:UseRelativeRateLimit", "true" }, { "DisCatSharp:Discord:LogTimestampFormat", "yyyy-MM-dd HH:mm:ss zzz" }, { "DisCatSharp:Discord:LargeThreshold", "250" }, { "DisCatSharp:Discord:AutoReconnect", "true" }, { "DisCatSharp:Discord:ShardId", "123123" }, { "DisCatSharp:Discord:GatewayCompressionLevel", "Stream" }, { "DisCatSharp:Discord:MessageCacheSize", "1024" }, { "DisCatSharp:Discord:HttpTimeout", "00:00:20" }, { "DisCatSharp:Discord:ReconnectIndefinitely", "false" }, { "DisCatSharp:Discord:AlwaysCacheMembers", "true" }, { "DisCatSharp:Discord:DiscordIntents", "AllUnprivileged" }, { "DisCatSharp:Discord:MobileStatus", "false" }, { "DisCatSharp:Discord:UseCanary", "false" }, { "DisCatSharp:Discord:AutoRefreshChannelCache", "false" }, { "DisCatSharp:Discord:Intents", "AllUnprivileged" } }; public Dictionary DiscordInteractivity() => new (this.DefaultDiscord()) { {"DisCatSharp:Using","[\"DisCatSharp.Interactivity\"]"}, }; public Dictionary DiscordInteractivityAndLavalink() => new (this.DefaultDiscord()) { {"DisCatSharp:Using","[\"DisCatSharp.Interactivity\", \"DisCatSharp.Lavalink\"]"}, }; IHostBuilder Create(Dictionary configValues) => Host.CreateDefaultBuilder() .ConfigureServices(services => services.AddSingleton()) .ConfigureHostConfiguration(builder => builder.AddInMemoryCollection(configValues)); + IHostBuilder Create(string filename) => + Host.CreateDefaultBuilder() + .ConfigureServices(services => services.AddSingleton()) + .ConfigureHostConfiguration(builder => builder.AddJsonFile(filename)); + + [Fact] + public void TestDifferentSection_InteractivityOnly() + { + IHost? host = null; + + try + { + host = this.Create("interactivity-different-section.json").Build(); + var service = host.Services.GetRequiredService(); + + Assert.NotNull(service); + Assert.NotNull(service.Client); + Assert.Null(service.Client.GetExtension()); + + var intents = DiscordIntents.GuildEmojisAndStickers | DiscordIntents.GuildMembers | + DiscordIntents.Guilds; + Assert.Equal(intents, service.Client.Intents); + + + var interactivity = service.Client.GetExtension(); + Assert.NotNull(interactivity); + } + finally + { + host?.Dispose(); + } + } + + [Fact] + public void TestDifferentSection_LavalinkOnly() + { + IHost? host = null; + + try + { + host = this.Create("lavalink-different-section.json").Build(); + var service = host.Services.GetRequiredService(); + + Assert.NotNull(service); + Assert.NotNull(service.Client); + Assert.NotNull(service.Client.GetExtension()); + Assert.Null(service.Client.GetExtension()); + + var intents = DiscordIntents.Guilds; + Assert.Equal(intents, service.Client.Intents); + } + finally + { + host?.Dispose(); + } + } + [Fact] public void TestNoExtensions() { IHost? host = null; try { host = this.Create(this.DefaultDiscord()).Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); Assert.NotNull(service.Client); } finally { host?.Dispose(); } } [Fact] public void TestInteractivityExtension() { IHost? host = null; try { host = this.Create(this.DiscordInteractivity()).Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); Assert.NotNull(service.Client); Assert.NotNull(service.Client.GetExtension()); } finally { host?.Dispose(); } } [Fact] public void TestInteractivityLavalinkExtensions() { IHost? host = null; try { host = this.Create(this.DiscordInteractivityAndLavalink()).Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); Assert.NotNull(service.Client); Assert.NotNull(service.Client.GetExtension()); Assert.NotNull(service.Client.GetExtension()); } finally { host?.Dispose(); } } } } diff --git a/DisCatSharp.Hosting.Tests/interactivity-different-section.json b/DisCatSharp.Hosting.Tests/interactivity-different-section.json new file mode 100644 index 000000000..1fdcef8fe --- /dev/null +++ b/DisCatSharp.Hosting.Tests/interactivity-different-section.json @@ -0,0 +1,12 @@ +{ + "MyCustomBot": { + "Using": [ + "Interactivity" + ], + + "Discord": { + "Token": "1234567890", + "Intents": "GuildEmojisAndStickers,GuildMembers,Guilds" + } + } +} diff --git a/DisCatSharp.Hosting.Tests/lavalink-different-section.json b/DisCatSharp.Hosting.Tests/lavalink-different-section.json new file mode 100644 index 000000000..91d48b92f --- /dev/null +++ b/DisCatSharp.Hosting.Tests/lavalink-different-section.json @@ -0,0 +1,12 @@ +{ + "MyCustomBot": { + "Using": [ + "Lavalink" + ], + + "Discord": { + "Token": "1234567890", + "Intents": "Guilds" + } + } +} diff --git a/DisCatSharp.Hosting/DiscordHostedService.cs b/DisCatSharp.Hosting/DiscordHostedService.cs index 39099f0c6..5cecc59c9 100644 --- a/DisCatSharp.Hosting/DiscordHostedService.cs +++ b/DisCatSharp.Hosting/DiscordHostedService.cs @@ -1,157 +1,159 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using DisCatSharp.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace DisCatSharp.Hosting { /// /// Simple implementation for to work as a /// public abstract class DiscordHostedService : BackgroundService, IDiscordHostedService { /// public DiscordClient Client { get; private set; } protected readonly ILogger Logger; #pragma warning disable 8618 /// /// Initializes a new instance of the class. /// /// The config. /// The logger. /// The provider. - protected DiscordHostedService(IConfiguration config, ILogger logger, IServiceProvider provider) + /// Name within the configuration which contains the config info for our bot. Default is DisCatSharp + protected DiscordHostedService(IConfiguration config, ILogger logger, IServiceProvider provider, string configBotSection = Configuration.ConfigurationExtensions.DefaultRootLib) { this.Logger = logger; - this.Initialize(config, provider); + this.Initialize(config, provider, configBotSection); } #pragma warning restore 8618 /// /// Automatically search for and configure /// /// /// - private void Initialize(IConfiguration config, IServiceProvider provider) + /// Name within the configuration which contains the config info for our bot + private void Initialize(IConfiguration config, IServiceProvider provider, string configBotSection) { - var typeMap = config.FindImplementedExtensions(); + var typeMap = config.FindImplementedExtensions(configBotSection); this.Logger.LogDebug($"Found the following config types: {string.Join("\n\t", typeMap.Keys)}"); - this.Client = config.BuildClient(); + this.Client = config.BuildClient(configBotSection); foreach (var typePair in typeMap) try { /* If section is null --> utilize the default constructor This means the extension was explicitly added in the 'Using' array, but user did not wish to override any value(s) in the extension's config */ var configInstance = typePair.Value.Section.HasValue ? typePair.Value.Section.Value.ExtractConfig(() => ActivatorUtilities.CreateInstance(provider, typePair.Value.ConfigType)) : ActivatorUtilities.CreateInstance(provider, typePair.Value.ConfigType); /* Explanation for bindings Internal Constructors --> NonPublic Public Constructors --> Public Constructors --> Instance */ var flags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance; var ctors = typePair.Value.ImplementationType.GetConstructors(flags); var instance = ctors.Any(x => x.GetParameters().Length == 1 && x.GetParameters().First().ParameterType == typePair.Value.ConfigType) ? Activator.CreateInstance(typePair.Value.ImplementationType, flags, null, new[] { configInstance }, null) : Activator.CreateInstance(typePair.Value.ImplementationType, true); /* Certain extensions do not require a configuration argument Those who do -- pass config instance in, Those who don't -- simply instantiate ActivatorUtilities requires a public constructor, anything with internal breaks */ if (instance == null) { this.Logger.LogError($"Unable to instantiate '{typePair.Value.ImplementationType.Name}'"); continue; } // Add an easy reference to our extensions for later use this.Client.AddExtension((BaseExtension)instance); } catch (Exception ex) { this.Logger.LogError($"Unable to register '{typePair.Value.ImplementationType.Name}': \n\t{ex.Message}"); } } /// /// Executes the bot. /// /// The stopping token. /// A Task. protected override async Task ExecuteAsync(CancellationToken stoppingToken) { if (this.Client == null) throw new NullReferenceException("Discord Client cannot be null"); await this.PreConnect(); await this.Client.ConnectAsync(); await this.PostConnect(); // Wait indefinitely -- but use stopping token so we can properly cancel if needed await Task.Delay(-1, stoppingToken); } /// /// Runs just prior to the bot connecting /// protected virtual Task PreConnect() => Task.CompletedTask; /// /// Runs immediately after the bot connects /// protected virtual Task PostConnect() => Task.CompletedTask; } } diff --git a/DisCatSharp.Hosting/IDiscordHostedService.cs b/DisCatSharp.Hosting/IDiscordHostedService.cs index 353a4a99e..22c5ee59f 100644 --- a/DisCatSharp.Hosting/IDiscordHostedService.cs +++ b/DisCatSharp.Hosting/IDiscordHostedService.cs @@ -1,38 +1,36 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System.Collections.Generic; - namespace DisCatSharp.Hosting { /// /// Contract required for to work in a web hosting environment /// public interface IDiscordHostedService : Microsoft.Extensions.Hosting.IHostedService { /// /// Reference to connected client /// DiscordClient Client { get; } } }