diff --git a/DisCatSharp.Configuration.Tests/ConfigurationExtensionTests.cs b/DisCatSharp.Configuration.Tests/ConfigurationExtensionTests.cs index b5feafbb5..17e490d1a 100644 --- a/DisCatSharp.Configuration.Tests/ConfigurationExtensionTests.cs +++ b/DisCatSharp.Configuration.Tests/ConfigurationExtensionTests.cs @@ -1,308 +1,308 @@ using System; using System.Collections.Generic; using System.Linq; using DisCatSharp.Configuration.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Xunit; namespace DisCatSharp.Configuration.Tests { public class ConfigurationExtensionTests { #region Test Classes class SampleClass { public int Amount { get; set; } public string? Email { get; set; } } class ClassWithArray { public int[] Values { get; set; } = { 1, 2, 3, 4, 5 }; public string[] Strings { get; set; } = { "1", "2", "3", "4", "5" }; } class ClassWithEnumerable { public IEnumerable Values { get; set; } = new[] { 1, 2, 3, 4, 5 }; public IEnumerable Strings { get; set; } = new[] { "1", "2", "3", "4", "5" }; } class ClassWithList { public List Strings { get; set; } = new List { "1", "2", "3", "4", "5" }; public List Values { get; set; } = new List { 1, 2, 3, 4, 5 }; } class SampleClass2 { public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(7); public string Name { get; set; } = "Sample"; public string ConstructorValue { get; } public SampleClass2(string value) { this.ConstructorValue = value; } } #endregion private IConfiguration EnumerableTestConfiguration() => new ConfigurationBuilder() .AddJsonFile("enumerable-test.json") .Build(); private IConfiguration HasSectionWithSuffixConfiguration() => new ConfigurationBuilder() .AddJsonFile("section-with-suffix.json") .Build(); private IConfiguration HasSectionNoSuffixConfiguration() => new ConfigurationBuilder() .AddJsonFile("section-no-suffix.json") .Build(); private IConfiguration BasicDiscordConfiguration() => new ConfigurationBuilder() .AddJsonFile("default-discord.json") .Build(); private IConfiguration DiscordIntentsConfig() => new ConfigurationBuilder() .AddJsonFile("intents-discord.json") .Build(); private IConfiguration DiscordHaphazardConfig() => new ConfigurationBuilder() .AddJsonFile("haphazard-discord.json") .Build(); private IConfiguration SampleConfig() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "Sample:Amount", "200" }, { "Sample:Email", "test@gmail.com" } }) .Build(); private IConfiguration SampleClass2Configuration_Default() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { {"Random:Stuff", "Meow"}, {"SampleClass2:Name", "Purfection"} }) .Build(); private IConfiguration SampleClass2Configuration_Change() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "SampleClass:Timeout", "01:30:00" }, { "SampleClass:NotValid", "Something" } }) .Build(); private IConfiguration SampleClass2EnumerableTest() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "SampleClass:EnumerableTest", "[\"10\",\"20\",\"30\"]" } }) .Build(); private IConfiguration SampleClass2ArrayTest() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "SampleClass:ArrayTest", "[\"10\",\"20\",\"30\"]" } }) .Build(); private IConfiguration SampleClass2ListTest() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "SampleClass:ListTest", "[\"10\",\"20\",\"30\"]" } }) .Build(); [Fact] public void TestExtractDiscordConfig_Intents() { var source = this.DiscordIntentsConfig(); DiscordConfiguration config = source.ExtractConfig("Discord"); var expected = DiscordIntents.GuildEmojisAndStickers | DiscordIntents.GuildMembers | DiscordIntents.GuildInvites | DiscordIntents.GuildMessageReactions; Assert.Equal(expected, config.Intents); } [Fact] public void TestExtractDiscordConfig_Haphzard() { var source = this.DiscordHaphazardConfig(); DiscordConfiguration config = source.ExtractConfig("Discord"); var expectedIntents = DiscordIntents.GuildEmojisAndStickers | DiscordIntents.GuildMembers | DiscordIntents.Guilds; Assert.Equal(expectedIntents, config.Intents); Assert.True(config.MobileStatus); Assert.Equal(1000, config.LargeThreshold); Assert.Equal(TimeSpan.FromHours(10), config.HttpTimeout); } [Fact] public void TestExtractDiscordConfig_Default() { var source = this.BasicDiscordConfiguration(); DiscordConfiguration config = source.ExtractConfig("Discord"); Assert.Equal("1234567890", config.Token); Assert.Equal(TokenType.Bot, config.TokenType); Assert.Equal(LogLevel.Information, config.MinimumLogLevel); Assert.True(config.UseRelativeRatelimit); Assert.Equal("yyyy-MM-dd HH:mm:ss zzz", config.LogTimestampFormat); Assert.Equal(250, config.LargeThreshold); Assert.True(config.AutoReconnect); Assert.Equal(123123, config.ShardId); Assert.Equal(GatewayCompressionLevel.Stream, config.GatewayCompressionLevel); Assert.Equal(1024, config.MessageCacheSize); Assert.Equal(TimeSpan.FromSeconds(20), config.HttpTimeout); Assert.False(config.ReconnectIndefinitely); Assert.True(config.AlwaysCacheMembers); Assert.Equal(DiscordIntents.AllUnprivileged, config.Intents); Assert.False(config.MobileStatus); Assert.False(config.UseCanary); Assert.False(config.AutoRefreshChannelCache); } [Fact] public void TestSection() { var source = this.SampleConfig(); SampleClass config = source.ExtractConfig("Sample", null); Assert.Equal(200, config.Amount); Assert.Equal("test@gmail.com", config.Email); } [Fact] public void TestExtractConfig_V2_Default() { var source = this.SampleClass2Configuration_Default(); var config = (SampleClass2) source.ExtractConfig("SampleClass", () => new SampleClass2("Test"), null); Assert.Equal(TimeSpan.FromMinutes(7), config.Timeout); Assert.Equal("Test", config.ConstructorValue); Assert.Equal("Sample", config.Name); } [Fact] public void TestExtractConfig_V2_Change() { var source = this.SampleClass2Configuration_Change(); var config = (SampleClass2) source.ExtractConfig("SampleClass", () => new SampleClass2("Test123"), null); var span = new TimeSpan(0, 1, 30, 0); Assert.Equal(span, config.Timeout); Assert.Equal("Test123", config.ConstructorValue); Assert.Equal("Sample", config.Name); } [Fact] public void TestExtractConfig_V3_Default() { var source = this.SampleClass2Configuration_Default(); var config = (SampleClass2)new ConfigSection(ref source, "SampleClass", null).ExtractConfig(() => new SampleClass2("Meow")); Assert.Equal("Meow", config.ConstructorValue); Assert.Equal(TimeSpan.FromMinutes(7), config.Timeout); Assert.Equal("Sample", config.Name); } [Fact] public void TestExtractConfig_V3_Change() { var source = this.SampleClass2Configuration_Change(); var config = (SampleClass2)new ConfigSection(ref source, "SampleClass", null).ExtractConfig(() => new SampleClass2("Meow")); Assert.Equal("Meow", config.ConstructorValue); var span = new TimeSpan(0, 1, 30, 0); Assert.Equal(span, config.Timeout); Assert.Equal("Sample", config.Name); } [Fact] public void TestExtractConfig_Enumerable() { var source = this.EnumerableTestConfiguration(); var config = (ClassWithEnumerable)new ConfigSection(ref source, "ClassWithEnumerable", null).ExtractConfig(() => new ClassWithEnumerable()); Assert.NotNull(config.Values); Assert.Equal(3, config.Values.Count()); Assert.NotNull(config.Strings); Assert.Equal(3, config.Values.Count()); } [Fact] public void TestExtractConfig_Array() { var source = this.EnumerableTestConfiguration(); var config = (ClassWithArray)new ConfigSection(ref source, "ClassWithArray", null).ExtractConfig(() => new ClassWithArray()); Assert.NotNull(config.Values); Assert.Equal(3, config.Values.Length); Assert.NotNull(config.Strings); Assert.Equal(3, config.Values.Length); } [Fact] public void TestExtractConfig_List() { var source = this.EnumerableTestConfiguration(); var config = (ClassWithList)new ConfigSection(ref source, "ClassWithList", null).ExtractConfig(() => new ClassWithList()); Assert.NotNull(config.Values); Assert.Equal(3, config.Values.Count); Assert.NotNull(config.Strings); Assert.Equal(3, config.Values.Count); } [Fact] public void TestHasSectionWithSuffix() { var source = this.HasSectionWithSuffixConfiguration(); - Assert.True(source.HasSection("DiscordConfiguration", null)); + Assert.True(source.HasSection("DiscordConfiguration")); Assert.False(source.HasSection("Discord")); - Assert.False(source.HasSection("DiscordConfiguration")); + Assert.False(source.HasSection("DiscordConfiguration", null)); } [Fact] public void TestHasSectionNoSuffix() { var source = this.HasSectionNoSuffixConfiguration(); - Assert.True(source.HasSection("Discord", null)); + Assert.True(source.HasSection("Discord")); Assert.False(source.HasSection("DiscordConfiguration")); - Assert.False(source.HasSection("Discord")); + Assert.False(source.HasSection("Discord", null)); } } } diff --git a/DisCatSharp.Configuration/ConfigurationExtensions.cs b/DisCatSharp.Configuration/ConfigurationExtensions.cs index 3edf8a625..4047784e8 100644 --- a/DisCatSharp.Configuration/ConfigurationExtensions.cs +++ b/DisCatSharp.Configuration/ConfigurationExtensions.cs @@ -1,213 +1,228 @@ // 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 System.Reflection; using DisCatSharp.Configuration.Models; using Microsoft.Extensions.Configuration; namespace DisCatSharp.Configuration { internal static class ConfigurationExtensions { private const string FactoryErrorMessage = "Require a function which provides a default entity to work with"; public const string DefaultRootLib = "DisCatSharp"; /// /// 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) { PropertyInfo[] 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; string 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)) { if (string.IsNullOrEmpty(section.GetValue(prop.Name))) value = section.Config .GetSection(section.GetPath(prop.Name)).Get(prop.PropertyType); else value = 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 object 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 object 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, string sectionName, - string? rootSectionName = DefaultRootLib) + public static bool HasSection(this IConfiguration config, params string[] values) { - if (!string.IsNullOrEmpty(rootSectionName)) - return config.GetSection(rootSectionName).GetChildren().Any(x => x.Key == sectionName); + if (!values.Any()) + return false; + + if (values.Length == 1) + return config.GetChildren().Any(x => x.Key == values[0]); + + int index = 0; + if (config.GetChildren().All(x => x.Key != values[0])) + return false; + + IConfigurationSection current = config.GetSection(values[0]); + + for (int i = 1; i < values.Length - 1; i++) + { + if (current.GetChildren().All(x => x.Key != values[i])) + return false; + + current = current.GetSection(values[i]); + } - return config.GetChildren().Any(x => x.Key == sectionName); + return current.GetChildren().Any(x=>x.Key == values[^1]); } } } diff --git a/DisCatSharp.Hosting.Tests/DisCatSharp.Hosting.Tests.csproj b/DisCatSharp.Hosting.Tests/DisCatSharp.Hosting.Tests.csproj index 1a4e43783..bd43e41d4 100644 --- a/DisCatSharp.Hosting.Tests/DisCatSharp.Hosting.Tests.csproj +++ b/DisCatSharp.Hosting.Tests/DisCatSharp.Hosting.Tests.csproj @@ -1,31 +1,38 @@ net5.0 enable false + runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive all + + + Always + + + diff --git a/DisCatSharp.Hosting.Tests/ExtensionTests.cs b/DisCatSharp.Hosting.Tests/ExtensionTests.cs index 5662c5854..3e4efa479 100644 --- a/DisCatSharp.Hosting.Tests/ExtensionTests.cs +++ b/DisCatSharp.Hosting.Tests/ExtensionTests.cs @@ -1,100 +1,97 @@ using System; using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.Configuration; using Xunit; using DisCatSharp.Interactivity; using DisCatSharp.Lavalink; namespace DisCatSharp.Hosting.Tests { public class HostExtensionTests { #region Reference to external assemblies - required to ensure they're loaded #pragma warning disable 414 private InteractivityConfiguration? _interactivityConfig = null; private LavalinkConfiguration? _lavalinkConfig = null; private DiscordConfiguration? _discordConfig = null; #pragma warning restore 414 #endregion 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 IConfiguration DiscordInteractivityConfiguration() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary(this.DefaultDiscord()) { {"DisCatSharp:Using", "[\"DisCatSharp.Interactivity\"]"} // this should be enough to automatically add the extension }) .Build(); public IConfiguration DiscordOnlyConfiguration() => new ConfigurationBuilder() .AddInMemoryCollection(this.DefaultDiscord()) .Build(); public IConfiguration DiscordInteractivityAndLavaLinkConfiguration() => new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary(this.DefaultDiscord()) - { - {"DisCatSharp:Using", "[\"DisCatSharp.Interactivity\",\"DisCatSharp.Lavalink\"]"} - }) + .AddJsonFile("interactivity-lavalink.json") .Build(); [Fact] public void DiscoverExtensions_Interactivity() { var source = this.DiscordInteractivityConfiguration(); var discovered = source.FindImplementedExtensions(); // Remember that DiscordConfiguration does not have an implementation type which is assignable to BaseExtension Assert.Single(discovered); var item = discovered.First(); Assert.Equal(typeof(InteractivityConfiguration), item.Value.ConfigType); Assert.Equal(typeof(InteractivityExtension), item.Value.ImplementationType); Assert.Equal("InteractivityExtension", item.Key); } [Fact] public void DiscoverExtensions_InteractivityAndLavaLink() { var source = this.DiscordInteractivityAndLavaLinkConfiguration(); var discovered = source.FindImplementedExtensions(); Assert.Equal(2, discovered.Count); var first = discovered.First(); var last = discovered.Last(); Assert.Equal(typeof(InteractivityConfiguration), first.Value.ConfigType); Assert.Equal(typeof(InteractivityExtension), first.Value.ImplementationType); Assert.True("InteractivityExtension".Equals(first.Key, StringComparison.OrdinalIgnoreCase)); Assert.Equal(typeof(LavalinkConfiguration), last.Value.ConfigType); Assert.Equal(typeof(LavalinkExtension), last.Value.ImplementationType); Assert.True("LavalinkExtension".Equals(last.Key, StringComparison.OrdinalIgnoreCase)); } } } diff --git a/DisCatSharp.Hosting.Tests/interactivity-lavalink.json b/DisCatSharp.Hosting.Tests/interactivity-lavalink.json new file mode 100644 index 000000000..1121fb529 --- /dev/null +++ b/DisCatSharp.Hosting.Tests/interactivity-lavalink.json @@ -0,0 +1,8 @@ +{ + "DisCatSharp": { + "Using": [ + "Interactivity", + "Lavalink" + ] + } +} diff --git a/DisCatSharp.Hosting/ConfigurationExtensions.cs b/DisCatSharp.Hosting/ConfigurationExtensions.cs index 4a99afc45..3005a9245 100644 --- a/DisCatSharp.Hosting/ConfigurationExtensions.cs +++ b/DisCatSharp.Hosting/ConfigurationExtensions.cs @@ -1,190 +1,232 @@ // 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 System.Linq; using System.Reflection; using DisCatSharp.Configuration; using DisCatSharp.Configuration.Models; using Microsoft.Extensions.Configuration; namespace DisCatSharp.Hosting { internal struct ExtensionConfigResult { - public ConfigSection Section { get; set; } + public ConfigSection? Section { get; set; } public Type ConfigType { get; set; } public Type ImplementationType { get; set; } } internal static class ConfigurationExtensions { /// /// Find assemblies that match the names provided via . /// /// /// In some cases the assembly the user is after could be used in the application but /// not appear within the .
/// The workaround for this is to check the assemblies in the , as well as referenced /// assemblies. If the targeted assembly is a reference, we need to load it into our workspace to get more info. ///
/// Names of assemblies to look for /// Assemblies which meet the given names. No duplicates public static Assembly[] FindAssemblies(string[]? names) { /* There is a possibility that an assembly can be referenced in multiple assemblies. To alleviate duplicates we need to shrink our queue as we find things */ List results = new(); if (names is null) return results.ToArray(); List queue = new(names); foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { if (!queue.Any()) break; var loadedAssemblyName = assembly.GetName().Name; // Kinda need the name to do our thing if (loadedAssemblyName == null) continue; // Is this something we're looking for? if (queue.Contains(loadedAssemblyName)) { results.Add(assembly); // Shrink queue so we don't accidentally add the same assembly > 1 times queue.Remove(loadedAssemblyName); } // Time to check if one of the referenced assemblies is something we're looking for foreach(var referencedAssembly in assembly.GetReferencedAssemblies() .Where(x => x.Name != null && queue.Contains(x.Name))) try { // Must load the assembly into our workspace so we can do stuff with it later results.Add(Assembly.Load(referencedAssembly)); #pragma warning disable 8604 queue.Remove(referencedAssembly.Name); #pragma warning restore 8604 } catch (Exception ex) { Console.Error.WriteLine($"Unable to load referenced assembly: '{referencedAssembly.Name}' \n\t{ex.Message}"); } } return results.ToArray(); } + /// + /// 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": { } + /// } + /// } + /// + ///
+ /// + /// Instance of + public static DiscordClient BuildClient(this IConfiguration config) + { + var section = config.HasSection(Constants.LibName, "Discord") + ? "Discord" + : config.HasSection(Constants.LibName, $"Discord{Constants.ConfigSuffix}") + ? $"Discord:{Constants.ConfigSuffix}" + : null; + + if (string.IsNullOrEmpty(section)) + return new DiscordClient(new()); + + return new DiscordClient(config.ExtractConfig(section)); + } + /// /// Easily identify which configuration types have been added to the
/// This way we can dynamically load extensions without explicitly doing so ///
/// /// /// Dictionary where Key -> Name of implemented type
Value ->
public static Dictionary FindImplementedExtensions(this IConfiguration configuration, string rootName = Configuration.ConfigurationExtensions.DefaultRootLib) { if (string.IsNullOrEmpty(rootName)) throw new ArgumentNullException(nameof(rootName), "Root name must be provided"); Dictionary results = new(); string[]? assemblyNames; // Has the user defined a using section within the root name? - if (!configuration.HasSection("Using", rootName)) + if (!configuration.HasSection(rootName, "Using")) return results; /* There are 2 ways a user could list which assemblies are used "Using": "[\"Assembly.Name\"]" "Using": ["Assembly.Name"] JSON or as Text. */ if (string.IsNullOrEmpty(configuration[configuration.ConfigPath(rootName, "Using")])) assemblyNames = configuration.GetSection(configuration.ConfigPath(rootName, "Using")).Get(); else assemblyNames = Newtonsoft.Json.JsonConvert.DeserializeObject( configuration[configuration.ConfigPath(rootName, "Using")]); - foreach (var assembly in FindAssemblies(assemblyNames)) + #pragma warning disable 8604 + foreach (var assembly in FindAssemblies(assemblyNames.Select(x=> x.StartsWith(Constants.LibName) ? x : $"{Constants.LibName}.{x}").ToArray())) { ExtensionConfigResult result = new(); foreach (var type in assembly.ExportedTypes .Where(x => x.Name.EndsWith(Constants.ConfigSuffix) && !x.IsAbstract && !x.IsInterface)) { string sectionName = type.Name; string prefix = type.Name.Replace(Constants.ConfigSuffix, ""); result.ConfigType = type; // Does a section exist with the classname? (DiscordConfiguration - for instance) - if(configuration.HasSection( sectionName, rootName)) + if(configuration.HasSection( rootName, sectionName)) result.Section = new ConfigSection(ref configuration, type.Name, rootName); // Does a section exist with the classname minus Configuration? (Discord - for Instance) - else if (configuration.HasSection( prefix, rootName)) + else if (configuration.HasSection(rootName, prefix)) result.Section = new ConfigSection(ref configuration, prefix, rootName); - // We require the implemented type to exist so we'll continue onward - else - continue; + // IF THE SECTION IS NOT PROVIDED --> WE WILL USE DEFAULT CONFIG IMPLEMENTATION /* Now we need to find the type which should consume our config In the event a user has some "fluff" between prefix and suffix we'll just check for beginning and ending values. Type should not be an interface or abstract, should also be assignable to BaseExtension */ var implementationType = assembly.ExportedTypes.FirstOrDefault(x => !x.IsAbstract && !x.IsInterface && x.Name.StartsWith(prefix) && x.Name.EndsWith(Constants.ExtensionSuffix) && x.IsAssignableTo(typeof(BaseExtension))); // If the implementation type was found we can add it to our result set if (implementationType != null) { result.ImplementationType = implementationType; results.Add(implementationType.Name, result); } } } + #pragma warning restore 8604 return results; } + } } diff --git a/DisCatSharp.Hosting/DiscordHostedService.cs b/DisCatSharp.Hosting/DiscordHostedService.cs index 70436e1f5..15f9d2334 100644 --- a/DisCatSharp.Hosting/DiscordHostedService.cs +++ b/DisCatSharp.Hosting/DiscordHostedService.cs @@ -1,151 +1,158 @@ // 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 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 class DiscordHostedService : BackgroundService, IDiscordHostedService { /// public DiscordClient Client { get; private set; } private readonly ILogger _logger; #pragma warning disable 8618 public DiscordHostedService(IConfiguration config, ILogger logger, IServiceProvider provider) { this._logger = logger; this.Initialize(config, provider); } #pragma warning restore 8618 /// /// Automatically search for and configure /// /// /// private void Initialize(IConfiguration config, IServiceProvider provider) { var typeMap = config.FindImplementedExtensions(); this._logger.LogDebug($"Found the following config types: {string.Join("\n\t", typeMap.Keys)}"); - var section = config.HasSection("Discord") + var section = config.HasSection(Constants.LibName, "Discord") ? "Discord" - : config.HasSection($"Discord{Constants.ConfigSuffix}") + : config.HasSection(Constants.LibName, $"Discord{Constants.ConfigSuffix}") ? $"Discord{Constants.ConfigSuffix}" : null; // If not section was provided we'll still just use the default config if (string.IsNullOrEmpty(section)) this.Client = new DiscordClient(new()); else this.Client = new DiscordClient(config.ExtractConfig(section)); foreach (var typePair in typeMap) try { - // First retrieve our configuration! - object configInstance = typePair.Value.Section.ExtractConfig(() => - ActivatorUtilities.CreateInstance(provider, typePair.Value.ConfigType)); + /* + 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 + */ + + object 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 */ BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance; var ctors = typePair.Value.ImplementationType.GetConstructors(flags); object? instance; /* 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 (ctors.Any(x => x.GetParameters().Length == 1 && x.GetParameters().First().ParameterType == typePair.Value.ConfigType)) instance = Activator.CreateInstance(typePair.Value.ImplementationType, flags, null, new[] { configInstance }, null); else instance = Activator.CreateInstance(typePair.Value.ImplementationType, true); 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}"); } } 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; } }