Added marmalade subsystem to Ellie
This commit is contained in:
parent
fbfc01c7f3
commit
08756eeb5c
18 changed files with 1429 additions and 0 deletions
76
src/EllieBot/Common/Marmalade/Adapters/BehaviorAdapter.cs
Normal file
76
src/EllieBot/Common/Marmalade/Adapters/BehaviorAdapter.cs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
[DontAddToIocContainer]
|
||||||
|
public sealed class BehaviorAdapter : ICustomBehavior
|
||||||
|
{
|
||||||
|
private readonly WeakReference<Canary> _canaryWr;
|
||||||
|
private readonly IMarmaladeStrings _strings;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly string _name;
|
||||||
|
|
||||||
|
// unused
|
||||||
|
public int Priority
|
||||||
|
=> 0;
|
||||||
|
|
||||||
|
public BehaviorAdapter(WeakReference<Canary> canaryWr, IMarmaladeStrings strings, IServiceProvider services)
|
||||||
|
{
|
||||||
|
_canaryWr = canaryWr;
|
||||||
|
_strings = strings;
|
||||||
|
_services = services;
|
||||||
|
|
||||||
|
_name = canaryWr.TryGetTarget(out var canary)
|
||||||
|
? $"canary/{canary.GetType().Name}"
|
||||||
|
: "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
|
||||||
|
{
|
||||||
|
if (!_canaryWr.TryGetTarget(out var canary))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return await canary.ExecPreCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services),
|
||||||
|
moduleName,
|
||||||
|
command.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExecOnMessageAsync(IGuild? guild, IUserMessage msg)
|
||||||
|
{
|
||||||
|
if (!_canaryWr.TryGetTarget(out var canary))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return await canary.ExecOnMessageAsync(guild, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> TransformInput(
|
||||||
|
IGuild guild,
|
||||||
|
IMessageChannel channel,
|
||||||
|
IUser user,
|
||||||
|
string input)
|
||||||
|
{
|
||||||
|
if (!_canaryWr.TryGetTarget(out var canary))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await canary.ExecInputTransformAsync(guild, channel, user, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg)
|
||||||
|
{
|
||||||
|
if (!_canaryWr.TryGetTarget(out var canary))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await canary.ExecOnNoCommandAsync(guild, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask ExecPostCommandAsync(ICommandContext context, string moduleName, string commandName)
|
||||||
|
{
|
||||||
|
if (!_canaryWr.TryGetTarget(out var canary))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await canary.ExecPostCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services),
|
||||||
|
moduleName,
|
||||||
|
commandName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
=> _name;
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
internal class ContextFactory
|
||||||
|
{
|
||||||
|
public static AnyContext CreateNew(ICommandContext context, IMarmaladeStrings strings, IServiceProvider services)
|
||||||
|
=> context.Guild is null
|
||||||
|
? new DmContextAdapter(context, strings, services)
|
||||||
|
: new GuildContextAdapter(context, strings, services);
|
||||||
|
}
|
49
src/EllieBot/Common/Marmalade/Adapters/DmContextAdapter.cs
Normal file
49
src/EllieBot/Common/Marmalade/Adapters/DmContextAdapter.cs
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
public sealed class DmContextAdapter : DmContext
|
||||||
|
{
|
||||||
|
public override IMarmaladeStrings Strings { get; }
|
||||||
|
public override IDMChannel Channel { get; }
|
||||||
|
public override IUserMessage Message { get; }
|
||||||
|
public override ISelfUser Bot { get; }
|
||||||
|
public override IUser User
|
||||||
|
=> Message.Author;
|
||||||
|
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly Lazy<IEmbedBuilderService> _ebs;
|
||||||
|
private readonly Lazy<IBotStrings> _botStrings;
|
||||||
|
private readonly Lazy<ILocalization> _localization;
|
||||||
|
|
||||||
|
public DmContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services)
|
||||||
|
{
|
||||||
|
if (ctx is not { Channel: IDMChannel ch})
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Can't use non-dm context to create DmContextAdapter", nameof(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
Strings = strings;
|
||||||
|
|
||||||
|
_services = services;
|
||||||
|
|
||||||
|
Channel = ch;
|
||||||
|
Message = ctx.Message;
|
||||||
|
Bot = ctx.Client.CurrentUser;
|
||||||
|
|
||||||
|
_ebs = new(_services.GetRequiredService<IEmbedBuilderService>());
|
||||||
|
_botStrings = new(_services.GetRequiredService<IBotStrings>);
|
||||||
|
_localization = new(_services.GetRequiredService<ILocalization>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IEmbedBuilder Embed()
|
||||||
|
=> _ebs.Value.Create();
|
||||||
|
|
||||||
|
public override string GetText(string key, object[]? args = null)
|
||||||
|
{
|
||||||
|
var cultureInfo = _localization.Value.GetCultureInfo(default(ulong?));
|
||||||
|
var output = Strings.GetText(key, cultureInfo, args ?? Array.Empty<object>());
|
||||||
|
if (!string.IsNullOrEmpty(output))
|
||||||
|
return output;
|
||||||
|
|
||||||
|
return _botStrings.Value.GetText(key, cultureInfo, args);
|
||||||
|
}
|
||||||
|
}
|
31
src/EllieBot/Common/Marmalade/Adapters/FilterAdapter.cs
Normal file
31
src/EllieBot/Common/Marmalade/Adapters/FilterAdapter.cs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
namespace Ellie.Marmalade.Adapters;
|
||||||
|
|
||||||
|
public class FilterAdapter : PreconditionAttribute
|
||||||
|
{
|
||||||
|
private readonly FilterAttribute _filterAttribute;
|
||||||
|
private readonly IMarmaladeStrings _strings;
|
||||||
|
|
||||||
|
public FilterAdapter(FilterAttribute filterAttribute,
|
||||||
|
IMarmaladeStrings strings)
|
||||||
|
{
|
||||||
|
_filterAttribute = filterAttribute;
|
||||||
|
_strings = strings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<PreconditionResult> CheckPermissionsAsync(
|
||||||
|
ICommandContext context,
|
||||||
|
CommandInfo command,
|
||||||
|
IServiceProvider services)
|
||||||
|
{
|
||||||
|
var marmaladeContext = ContextAdapterFactory.CreateNew(context,
|
||||||
|
_strings,
|
||||||
|
services);
|
||||||
|
|
||||||
|
var result = await _filterAttribute.CheckAsync(marmaladeContext);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return PreconditionResult.FromError($"Precondition '{_filterAttribute.GetType().Name}' failed.");
|
||||||
|
|
||||||
|
return PreconditionResult.FromSuccess();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
public sealed class GuildContextAdapter : GuildContext
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ICommandContext _ctx;
|
||||||
|
private readonly Lazy<IEmbedBuilderService> _ebs;
|
||||||
|
private readonly Lazy<IBotStrings> _botStrings;
|
||||||
|
private readonly Lazy<ILocalization> _localization;
|
||||||
|
|
||||||
|
public override IMarmaladeStrings Strings { get; }
|
||||||
|
public override IGuild Guild { get; }
|
||||||
|
public override ITextChannel Channel { get; }
|
||||||
|
public override ISelfUser Bot { get; }
|
||||||
|
public override IUserMessage Message
|
||||||
|
=> _ctx.Message;
|
||||||
|
|
||||||
|
public override IGuildUser User { get; }
|
||||||
|
|
||||||
|
public override IEmbedBuilder Embed()
|
||||||
|
=> _ebs.Value.Create();
|
||||||
|
|
||||||
|
public GuildContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services)
|
||||||
|
{
|
||||||
|
if (ctx.Guild is not IGuild guild || ctx.Channel is not ITextChannel channel)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Can't use non-guild context to create GuildContextAdapter", nameof(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
Strings = strings;
|
||||||
|
User = (IGuildUser)ctx.User;
|
||||||
|
Bot = ctx.Client.CurrentUser;
|
||||||
|
|
||||||
|
_services = services;
|
||||||
|
_ebs = new(_services.GetRequiredService<IEmbedBuilderService>());
|
||||||
|
_botStrings = new(_services.GetRequiredService<IBotStrings>);
|
||||||
|
_localization = new(_services.GetRequiredService<ILocalization>());
|
||||||
|
|
||||||
|
(_ctx, Guild, Channel) = (ctx, guild, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string GetText(string key, object[]? args = null)
|
||||||
|
{
|
||||||
|
args ??= Array.Empty<object>();
|
||||||
|
|
||||||
|
var cultureInfo = _localization.Value.GetCultureInfo(_ctx.Guild.Id);
|
||||||
|
var output = Strings.GetText(key, cultureInfo, args);
|
||||||
|
if (!string.IsNullOrWhiteSpace(output))
|
||||||
|
return output;
|
||||||
|
|
||||||
|
return _botStrings.Value.GetText(key, cultureInfo, args);
|
||||||
|
}
|
||||||
|
}
|
32
src/EllieBot/Common/Marmalade/Adapters/ParamParserAdapter.cs
Normal file
32
src/EllieBot/Common/Marmalade/Adapters/ParamParserAdapter.cs
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
public sealed class ParamParserAdapter<T> : TypeReader
|
||||||
|
{
|
||||||
|
private readonly ParamParser<T> _parser;
|
||||||
|
private readonly IMarmaladeStrings _strings;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
|
public ParamParserAdapter(ParamParser<T> parser,
|
||||||
|
IMarmaladeStrings strings,
|
||||||
|
IServiceProvider services)
|
||||||
|
{
|
||||||
|
_parser = parser;
|
||||||
|
_strings = strings;
|
||||||
|
_services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
|
||||||
|
ICommandContext context,
|
||||||
|
string input,
|
||||||
|
IServiceProvider services)
|
||||||
|
{
|
||||||
|
var marmaladeContext = ContextAdapterFactory.CreateNew(context,
|
||||||
|
_strings,
|
||||||
|
_services);
|
||||||
|
|
||||||
|
var result = await _parser.TryParseAsync(marmaladeContext, input);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
return Discord.Commands.TypeReaderResult.FromSuccess(result.Data);
|
||||||
|
|
||||||
|
return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, "Invalid input");
|
||||||
|
}
|
||||||
|
}
|
27
src/EllieBot/Common/Marmalade/CommandContextType.cs
Normal file
27
src/EllieBot/Common/Marmalade/CommandContextType.cs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enum specifying in which context the command can be executed
|
||||||
|
/// </summary>
|
||||||
|
public enum CommandContextType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Command can only be executed in a guild
|
||||||
|
/// </summary>
|
||||||
|
Guild,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command can only be executed in DMs
|
||||||
|
/// </summary>
|
||||||
|
Dm,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command can be executed anywhere
|
||||||
|
/// </summary>
|
||||||
|
Any,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command can be executed anywhere, and it doesn't require context to be passed to it
|
||||||
|
/// </summary>
|
||||||
|
Unspecified
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public interface IMarmaladeConfigService
|
||||||
|
{
|
||||||
|
IReadOnlyCollection<string> GetLoadedMarmalades();
|
||||||
|
void AddLoadedMarmalade(string name);
|
||||||
|
void RemoveLoadedMarmalade(string name);
|
||||||
|
}
|
20
src/EllieBot/Common/Marmalade/Config/MarmaladeConfig.cs
Normal file
20
src/EllieBot/Common/Marmalade/Config/MarmaladeConfig.cs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
#nullable disable
|
||||||
|
using Cloneable;
|
||||||
|
using EllieBot.Common.Yml;
|
||||||
|
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public sealed partial class MarmaladeConfig : ICloneable<MarmaladeConfig>
|
||||||
|
{
|
||||||
|
[Comment(@"DO NOT CHANGE")]
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
|
||||||
|
[Comment("List of marmalades automatically loaded at startup")]
|
||||||
|
public List<string>? Loaded { get; set; }
|
||||||
|
|
||||||
|
public MarmaladeConfig()
|
||||||
|
{
|
||||||
|
Loaded = new();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
using EllieBot.Common.Configs;
|
||||||
|
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public sealed class MarmaladeConfigService : ConfigServiceBase<MarmaladeConfig>, IMarmaladeConfigService
|
||||||
|
{
|
||||||
|
private const string FILE_PATH = "data/marmalades/marmalade.yml";
|
||||||
|
private static readonly TypedKey<MarmaladeConfig> _changeKey = new("config.marmalade.updated");
|
||||||
|
|
||||||
|
public override string Name
|
||||||
|
=> "marmalade";
|
||||||
|
|
||||||
|
public MarmaladeConfigService(
|
||||||
|
IConfigSeria serializer,
|
||||||
|
IPubSub pubSub)
|
||||||
|
: base(FILE_PATH, serializer, pubSub, _changeKey)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> GetLoadedMarmalades()
|
||||||
|
=> Data.Loaded?.ToList() ?? new List<string>();
|
||||||
|
|
||||||
|
public void AddLoadedMarmalade(string name)
|
||||||
|
{
|
||||||
|
ModifyConfig(conf =>
|
||||||
|
{
|
||||||
|
if (conf.Loaded is null)
|
||||||
|
conf.Loaded = new();
|
||||||
|
|
||||||
|
if (!conf.Loaded.Contains(name))
|
||||||
|
conf.Loaded.Add(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveLoadedMarmalade(string name)
|
||||||
|
{
|
||||||
|
ModifyConfig(conf =>
|
||||||
|
{
|
||||||
|
if (conf.Loaded is null)
|
||||||
|
conf.Loaded = new();
|
||||||
|
|
||||||
|
conf.Loaded.Remove(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
23
src/EllieBot/Common/Marmalade/IMarmaladeLoaderService.cs
Normal file
23
src/EllieBot/Common/Marmalade/IMarmaladeLoaderService.cs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public interface IMarmaladeLoaderService
|
||||||
|
{
|
||||||
|
Task<MarmaladeLoadResult> LoadMarmaladeAsync(string marmaladeName);
|
||||||
|
Task<MarmaladeUnloadResult> UnloadMarmaladeAsync(string marmaladeName);
|
||||||
|
string GetCommandDescription(string marmamaleName, string commandName, CultureInfo culture);
|
||||||
|
string[] GetCommandExampleArgs(string marmamaleName, string commandName, CultureInfo culture);
|
||||||
|
Task ReloadStrings();
|
||||||
|
IReadOnlyCollection<string> GetAllMarmalades();
|
||||||
|
IReadOnlyCollection<MarmaladeStats> GetLoadedMarmalades(CultureInfo? cultureInfo = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MarmaladeStats(string Name,
|
||||||
|
string? Description,
|
||||||
|
IReadOnlyCollection<CanaryStats> Canaries);
|
||||||
|
|
||||||
|
public sealed record CanaryStats(string Name,
|
||||||
|
IReadOnlyCollection<CanaryCommandStats> Commands);
|
||||||
|
|
||||||
|
public sealed record CanaryCommandStats(string Name);
|
|
@ -0,0 +1,36 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public sealed class MarmaladeAssemblyLoadContext : AssemblyLoadContext
|
||||||
|
{
|
||||||
|
private readonly AssemblyDependencyResolver _depResolver;
|
||||||
|
|
||||||
|
public MarmaladeAssemblyLoadContext(string pluginPath) : base(isCollectible: true)
|
||||||
|
{
|
||||||
|
_depResolver = new(pluginPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Assembly? Load(AssemblyName assemblyName)
|
||||||
|
{
|
||||||
|
var assemblyPath = _depResolver.ResolveAssemblyToPath(assemblyName);
|
||||||
|
if (assemblyPath != null)
|
||||||
|
{
|
||||||
|
return LoadFromAssemblyPath(assemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||||
|
{
|
||||||
|
var libraryPath = _depResolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||||
|
if (libraryPath != null)
|
||||||
|
{
|
||||||
|
return LoadUnmanagedDllFromPath(libraryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
}
|
917
src/EllieBot/Common/Marmalade/MarmaladeLoaderService.cs
Normal file
917
src/EllieBot/Common/Marmalade/MarmaladeLoaderService.cs
Normal file
|
@ -0,0 +1,917 @@
|
||||||
|
using Discord.Commands.Builders;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Ellie.Marmalade.Adapters;
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
// ReSharper disable RedundantAssignment
|
||||||
|
public sealed class MarmaladeLoaderService : IMarmaladeLoaderService, IReadyExecutor, IEService
|
||||||
|
{
|
||||||
|
private readonly CommandService _cmdService;
|
||||||
|
private readonly IServiceProvider _botServices;
|
||||||
|
private readonly IBehaviorHandler _behHandler;
|
||||||
|
private readonly IPubSub _pubSub;
|
||||||
|
private readonly IMarmaladeConfigService _marmaladeConfig;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, ResolvedMarmalade> _resolved = new();
|
||||||
|
#pragma warning disable IDE0090 // Use 'new(...)'
|
||||||
|
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
|
||||||
|
#pragma warning restore IDE0090 // Use 'new(...)'
|
||||||
|
|
||||||
|
private readonly TypedKey<string> _loadKey = new("marmalade:load");
|
||||||
|
private readonly TypedKey<string> _unloadKey = new("marmalade:unload");
|
||||||
|
|
||||||
|
private readonly TypedKey<string> _stringsReload = new("marmalade:reload_strings");
|
||||||
|
|
||||||
|
private const string BASE_DIR = "data/marmalades";
|
||||||
|
|
||||||
|
public MarmaladeLoaderService(CommandService cmdService,
|
||||||
|
IServiceProvider botServices,
|
||||||
|
IBehaviorHandler behHandler,
|
||||||
|
IPubSub pubSub,
|
||||||
|
IMarmaladeConfigService marmaladeConfig)
|
||||||
|
{
|
||||||
|
_cmdService = cmdService;
|
||||||
|
_botServices = botServices;
|
||||||
|
_behHandler = behHandler;
|
||||||
|
_pubSub = pubSub;
|
||||||
|
_marmaladeConfig = marmaladeConfig;
|
||||||
|
|
||||||
|
// has to be done this way to support this feature on sharded bots
|
||||||
|
_pubSub.Sub(_loadKey, async name => await InternalLoadAsync(name));
|
||||||
|
_pubSub.Sub(_unloadKey, async name => await InternalUnloadAsync(name));
|
||||||
|
|
||||||
|
_pubSub.Sub(_stringsReload, async _ => await ReloadStringsInternal());
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> GetAllMarmalades()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(BASE_DIR))
|
||||||
|
return Array.Empty<string>();
|
||||||
|
|
||||||
|
return Directory.GetDirectories(BASE_DIR)
|
||||||
|
.Select(x => Path.GetRelativePath(BASE_DIR, x))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
public IReadOnlyCollection<MarmaladeStats> GetLoadedMarmalades(CultureInfo? culture)
|
||||||
|
{
|
||||||
|
var toReturn = new List<MarmaladeStats>(_resolved.Count);
|
||||||
|
foreach (var (name, resolvedData) in _resolved)
|
||||||
|
{
|
||||||
|
var canaries = new List<CanaryStats>(resolvedData.CanaryInfos.Count);
|
||||||
|
|
||||||
|
foreach (var canaryInfos in resolvedData.CanaryInfos.Concat(resolvedData.CanaryInfos.SelectMany(x => x.Subcanaries)))
|
||||||
|
{
|
||||||
|
var commands = new List<CanaryCommandStats>();
|
||||||
|
|
||||||
|
foreach (var command in canaryInfos.Commands)
|
||||||
|
{
|
||||||
|
commands.Add(new CanaryCommandStats(command.Aliases.First()));
|
||||||
|
}
|
||||||
|
|
||||||
|
canaries.Add(new CanaryStats(canaryInfos.Name, commands));
|
||||||
|
}
|
||||||
|
|
||||||
|
toReturn.Add(new MarmaladeStats(name, resolvedData.Strings.GetDescription(culture), canaries));
|
||||||
|
}
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnReadyAsync()
|
||||||
|
{
|
||||||
|
foreach (var name in _marmaladeConfig.GetLoadedMarmalades())
|
||||||
|
{
|
||||||
|
var result = await InternalLoadAsync(name);
|
||||||
|
if (result != MarmaladeLoadResult.Success)
|
||||||
|
Log.Warning("Unable to load '{MarmaladeName}' marmalade", name);
|
||||||
|
else
|
||||||
|
Log.Warning("Loaded marmalade '{MarmaladeName}'", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
public async Task<MarmaladeLoadResult> LoadMarmaladeAsync(string marmaladeName)
|
||||||
|
{
|
||||||
|
// try loading on this shard first to see if it works
|
||||||
|
var res = await InternalLoadAsync(marmaladeName);
|
||||||
|
if (res == MarmaladeLoadResult.Success)
|
||||||
|
{
|
||||||
|
// if it does publish it so that other shards can load the medusa too
|
||||||
|
// this method will be ran twice on this shard but it doesn't matter as
|
||||||
|
// the second attempt will be ignored
|
||||||
|
await _pubSub.Pub(_loadKey, marmaladeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
public async Task<MarmaladeUnloadResult> UnloadMarmaladeAsync(string marmaladeName)
|
||||||
|
{
|
||||||
|
var res = await InternalUnloadAsync(marmaladeName);
|
||||||
|
if (res == MarmaladeUnloadResult.Success)
|
||||||
|
{
|
||||||
|
await _pubSub.Pub(_unloadKey, marmaladeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
public string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (!_resolved.TryGetValue(marmaladeName, out var data))
|
||||||
|
return Array.Empty<string>();
|
||||||
|
|
||||||
|
return data.Strings.GetCommandStrings(commandName, culture).Args
|
||||||
|
?? data.CanaryInfos
|
||||||
|
.SelectMany(x => x.Commands)
|
||||||
|
.FirstOrDefault(x => x.Aliases.Any(alias
|
||||||
|
=> alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
?.OptionalStrings
|
||||||
|
.Args
|
||||||
|
?? new[] { string.Empty };
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReloadStrings()
|
||||||
|
=> _pubSub.Pub(_stringsReload, true);
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private void ReloadStringsSync()
|
||||||
|
{
|
||||||
|
foreach (var resolved in _resolved.Values)
|
||||||
|
{
|
||||||
|
resolved.Strings.Reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadStringsInternal()
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReloadStringsSync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
public string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (!_resolved.TryGetValue(marmaladeName, out var data))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return data.Strings.GetCommandStrings(commandName, culture).Desc
|
||||||
|
?? data.CanaryInfos
|
||||||
|
.SelectMany(x => x.Commands)
|
||||||
|
.FirstOrDefault(x => x.Aliases.Any(alias
|
||||||
|
=> alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
?.OptionalStrings
|
||||||
|
.Desc
|
||||||
|
?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private async ValueTask<MarmaladeLoadResult> InternalLoadAsync(string name)
|
||||||
|
{
|
||||||
|
if (_resolved.ContainsKey(name))
|
||||||
|
return MarmaladeLoadResult.AlreadyLoaded;
|
||||||
|
|
||||||
|
var safeName = Uri.EscapeDataString(name);
|
||||||
|
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (LoadAssemblyInternal(safeName,
|
||||||
|
out var ctx,
|
||||||
|
out var canaryData,
|
||||||
|
out var services,
|
||||||
|
out var strings,
|
||||||
|
out var typeReaders))
|
||||||
|
{
|
||||||
|
var moduleInfos = new List<ModuleInfo>();
|
||||||
|
|
||||||
|
LoadTypeReadersInternal(typeReaders);
|
||||||
|
|
||||||
|
foreach (var point in canaryData)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// initialize canary and subcanaries
|
||||||
|
await point.Instance.InitializeAsync();
|
||||||
|
foreach (var sub in point.Subcanaries)
|
||||||
|
{
|
||||||
|
await sub.Instance.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var module = await LoadModuleInternalAsync(name, point, strings, services);
|
||||||
|
moduleInfos.Add(module);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex,
|
||||||
|
"Error loading canary {CanaryName}",
|
||||||
|
point.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var execs = GetExecsInternal(canaryData, strings, services);
|
||||||
|
await _behHandler.AddRangeAsync(execs);
|
||||||
|
|
||||||
|
_resolved[name] = new(LoadContext: ctx,
|
||||||
|
ModuleInfos: moduleInfos.ToImmutableArray(),
|
||||||
|
CanaryInfos: canaryData.ToImmutableArray(),
|
||||||
|
strings,
|
||||||
|
typeReaders,
|
||||||
|
execs)
|
||||||
|
{
|
||||||
|
Services = services
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
services = null;
|
||||||
|
_marmaladeConfig.AddLoadedMarmalade(safeName);
|
||||||
|
return MarmaladeLoadResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MarmaladeLoadResult.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return MarmaladeLoadResult.NotFound;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "An error occurred loading a marmalade");
|
||||||
|
return MarmaladeLoadResult.UnknownError;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private IReadOnlyCollection<ICustomBehavior> GetExecsInternal(IReadOnlyCollection<CanaryInfo> canaryData, IMarmaladeStrings strings, IServiceProvider services)
|
||||||
|
{
|
||||||
|
var behs = new List<ICustomBehavior>();
|
||||||
|
foreach (var canary in canaryData)
|
||||||
|
{
|
||||||
|
behs.Add(new BehaviorAdapter(new(canary.Instance), strings, services));
|
||||||
|
|
||||||
|
foreach (var sub in canary.Subcanaries)
|
||||||
|
{
|
||||||
|
behs.Add(new BehaviorAdapter(new(sub.Instance), strings, services));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return behs;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private void LoadTypeReadersInternal(Dictionary<Type, TypeReader> typeReaders)
|
||||||
|
{
|
||||||
|
var notAddedTypeReaders = new List<Type>();
|
||||||
|
foreach (var (type, typeReader) in typeReaders)
|
||||||
|
{
|
||||||
|
// if type reader for this type already exists, it will not be replaced
|
||||||
|
if (_cmdService.TypeReaders.Contains(type))
|
||||||
|
{
|
||||||
|
notAddedTypeReaders.Add(type);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cmdService.AddTypeReader(type, typeReader);
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the ones that were not added
|
||||||
|
// to prevent them from being unloaded later
|
||||||
|
// as they didn't come from this marmalade
|
||||||
|
foreach (var toRemove in notAddedTypeReaders)
|
||||||
|
{
|
||||||
|
typeReaders.Remove(toRemove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private bool LoadAssemblyInternal(
|
||||||
|
string safeName,
|
||||||
|
[NotNullWhen(true)] out WeakReference<MarmaladeAssemblyLoadContext>? ctxWr,
|
||||||
|
[NotNullWhen(true)] out IReadOnlyCollection<CanaryInfo>? canaryData,
|
||||||
|
out IServiceProvider services,
|
||||||
|
out IMarmaladeStrings strings,
|
||||||
|
out Dictionary<Type, TypeReader> typeReaders)
|
||||||
|
{
|
||||||
|
ctxWr = null;
|
||||||
|
canaryData = null;
|
||||||
|
|
||||||
|
var path = $"{BASE_DIR}/{safeName}/{safeName}.dll";
|
||||||
|
strings = MarmaladeStrings.CreateDefault($"{BASE_DIR}/{safeName}");
|
||||||
|
var ctx = new MarmaladeAssemblyLoadContext(Path.GetDirectoryName(path));
|
||||||
|
var a = ctx.LoadFromAssemblyPath(Path.GetFullPath(path));
|
||||||
|
var sis = LoadCanariesFromAssembly(a, out services);
|
||||||
|
typeReaders = LoadTypeReadersFromAssembly(a, strings, services);
|
||||||
|
|
||||||
|
if (sis.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxWr = new(ctx);
|
||||||
|
canaryData = sis;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static readonly Type _paramParserType = typeof(ParamParser<>);
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private Dictionary<Type, TypeReader> LoadTypeReadersFromAssembly(
|
||||||
|
Assembly assembly,
|
||||||
|
IMarmaladeStrings strings,
|
||||||
|
IServiceProvider services)
|
||||||
|
{
|
||||||
|
var paramParsers = assembly.GetExportedTypes()
|
||||||
|
.Where(x => x.IsClass
|
||||||
|
&& !x.IsAbstract
|
||||||
|
&& x.BaseType is not null
|
||||||
|
&& x.BaseType.IsGenericType
|
||||||
|
&& x.BaseType.GetGenericTypeDefinition() == _paramParserType);
|
||||||
|
|
||||||
|
var typeReaders = new Dictionary<Type, TypeReader>();
|
||||||
|
foreach (var parserType in paramParsers)
|
||||||
|
{
|
||||||
|
var parserObj = ActivatorUtilities.CreateInstance(services, parserType);
|
||||||
|
|
||||||
|
var targetType = parserType.BaseType!.GetGenericArguments()[0];
|
||||||
|
var typeReaderInstance = (TypeReader)Activator.CreateInstance(
|
||||||
|
typeof(ParamParserAdapter<>).MakeGenericType(targetType),
|
||||||
|
args: new[] { parserObj, strings, services })!;
|
||||||
|
|
||||||
|
typeReaders.Add(targetType, typeReaderInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeReaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private async Task<ModuleInfo> LoadModuleInternalAsync(string marmaladeName, CanaryInfo canaryInfo, IMarmaladeStrings strings, IServiceProvider services)
|
||||||
|
{
|
||||||
|
var module = await _cmdService.CreateModuleAsync(canaryInfo.Instance.Prefix,
|
||||||
|
CreateModuleFactory(marmaladeName, canaryInfo, strings, services));
|
||||||
|
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private Action<ModuleBuilder> CreateModuleFactory(
|
||||||
|
string marmaladeName,
|
||||||
|
CanaryInfo canaryInfo,
|
||||||
|
IMarmaladeStrings strings,
|
||||||
|
IServiceProvider marmaladeServices)
|
||||||
|
=> mb =>
|
||||||
|
{
|
||||||
|
var m = mb.WithName(canaryInfo.Name);
|
||||||
|
|
||||||
|
foreach (var f in canaryInfo.Filters)
|
||||||
|
{
|
||||||
|
m.AddPrecondition(new FilterAdapter(f, strings));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cmd in canaryInfo.Commands)
|
||||||
|
{
|
||||||
|
m.AddCommand(cmd.Aliases.First(),
|
||||||
|
CreateCallback(cmd.ContextType,
|
||||||
|
new(canaryInfo),
|
||||||
|
new(cmd),
|
||||||
|
new(marmaladeServices),
|
||||||
|
strings),
|
||||||
|
CreateCommandFactory(marmaladeName, cmd, strings));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var subInfo in canaryInfo.Subcanaries)
|
||||||
|
m.AddModule(subInfo.Instance.Prefix, CreateModuleFactory(marmaladeName, subInfo, strings, marmaladeServices));
|
||||||
|
};
|
||||||
|
|
||||||
|
#pragma warning disable IDE0090 // Use 'new(...)'
|
||||||
|
private static readonly RequireContextAttribute _reqGuild = new RequireContextAttribute(ContextType.Guild);
|
||||||
|
private static readonly RequireContextAttribute _reqDm = new RequireContextAttribute(ContextType.DM);
|
||||||
|
#pragma warning restore IDE0090 // Use 'new(...)'
|
||||||
|
private Action<CommandBuilder> CreateCommandFactory(string marmaladeName, CanaryCommandData cmd, IMarmaladeStrings strings)
|
||||||
|
=> (cb) =>
|
||||||
|
{
|
||||||
|
cb.AddAliases(cmd.Aliases.Skip(1).ToArray());
|
||||||
|
|
||||||
|
if (cmd.ContextType == CommandContextType.Guild)
|
||||||
|
cb.AddPrecondition(_reqGuild);
|
||||||
|
else if (cmd.ContextType == CommandContextType.Dm)
|
||||||
|
cb.AddPrecondition(_reqDm);
|
||||||
|
|
||||||
|
foreach (var f in cmd.Filters)
|
||||||
|
cb.AddPrecondition(new FilterAdapter(f, strings));
|
||||||
|
|
||||||
|
foreach (var ubp in cmd.UserAndBotPerms)
|
||||||
|
{
|
||||||
|
if (ubp is user_permAttribute up)
|
||||||
|
{
|
||||||
|
if (up.GuildPerm is { } gp)
|
||||||
|
cb.AddPrecondition(new UserPermAttribute(gp));
|
||||||
|
else if (up.ChannelPerm is { } cp)
|
||||||
|
cb.AddPrecondition(new UserPermAttribute(cp));
|
||||||
|
}
|
||||||
|
else if (ubp is bot_permAttribute bp)
|
||||||
|
{
|
||||||
|
if (bp.GuildPerm is { } gp)
|
||||||
|
cb.AddPrecondition(new BotPermAttribute(gp));
|
||||||
|
else if (bp.ChannelPerm is { } cp)
|
||||||
|
cb.AddPrecondition(new BotPermAttribute(cp));
|
||||||
|
}
|
||||||
|
else if (ubp is bot_owner_onlyAttribute)
|
||||||
|
{
|
||||||
|
cb.AddPrecondition(new OwnerOnlyAttribute());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.WithPriority(cmd.Priority);
|
||||||
|
|
||||||
|
// using summary to save method name
|
||||||
|
// method name is used to retrieve desc/usages
|
||||||
|
cb.WithRemarks($"marmalade///{marmaladeName}");
|
||||||
|
cb.WithSummary(cmd.MethodInfo.Name.ToLowerInvariant());
|
||||||
|
|
||||||
|
foreach (var param in cmd.Parameters)
|
||||||
|
{
|
||||||
|
cb.AddParameter(param.Name, param.Type, CreateParamFactory(param));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private Action<ParameterBuilder> CreateParamFactory(ParamData paramData)
|
||||||
|
=> (pb) =>
|
||||||
|
{
|
||||||
|
pb.WithIsMultiple(paramData.IsParams)
|
||||||
|
.WithIsOptional(paramData.IsOptional)
|
||||||
|
.WithIsRemainder(paramData.IsLeftover);
|
||||||
|
|
||||||
|
if (paramData.IsOptional)
|
||||||
|
pb.WithDefault(paramData.DefaultValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private Func<ICommandContext, object[], IServiceProvider, CommandInfo, Task> CreateCallback(
|
||||||
|
CommandContextType contextType,
|
||||||
|
WeakReference<CanaryInfo> canaryDataWr,
|
||||||
|
WeakReference<CanaryCommandData> canaryCommandDataWr,
|
||||||
|
WeakReference<IServiceProvider> marmaladeServicesWr,
|
||||||
|
IMarmaladeStrings strings)
|
||||||
|
=> async (context, parameters, svcs, _) =>
|
||||||
|
{
|
||||||
|
if (!canaryCommandDataWr.TryGetTarget(out var cmdData)
|
||||||
|
|| !canaryDataWr.TryGetTarget(out var canaryData)
|
||||||
|
|| !marmaladeServicesWr.TryGetTarget(out var marmaladeServices))
|
||||||
|
{
|
||||||
|
Log.Warning("Attempted to run an unloaded canary's command");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var paramObjs = ParamObjs(contextType, cmdData, parameters, context, svcs, marmaladeServices, strings);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var methodInfo = cmdData.MethodInfo;
|
||||||
|
if (methodInfo.ReturnType == typeof(Task)
|
||||||
|
|| (methodInfo.ReturnType.IsGenericType
|
||||||
|
&& methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)))
|
||||||
|
{
|
||||||
|
await (Task)methodInfo.Invoke(canaryData.Instance, paramObjs)!;
|
||||||
|
}
|
||||||
|
else if (methodInfo.ReturnType == typeof(ValueTask))
|
||||||
|
{
|
||||||
|
await ((ValueTask)methodInfo.Invoke(canaryData.Instance, paramObjs)!).AsTask();
|
||||||
|
}
|
||||||
|
else // if (methodInfo.ReturnType == typeof(void))
|
||||||
|
{
|
||||||
|
methodInfo.Invoke(canaryData.Instance, paramObjs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
paramObjs = null;
|
||||||
|
cmdData = null;
|
||||||
|
|
||||||
|
canaryData = null;
|
||||||
|
marmaladeServices = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private static object[] ParamObjs(
|
||||||
|
CommandContextType contextType,
|
||||||
|
CanaryCommandData cmdData,
|
||||||
|
object[] parameters,
|
||||||
|
ICommandContext context,
|
||||||
|
IServiceProvider svcs,
|
||||||
|
IServiceProvider svcProvider,
|
||||||
|
IMarmaladeStrings strings)
|
||||||
|
{
|
||||||
|
var extraParams = contextType == CommandContextType.Unspecified ? 0 : 1;
|
||||||
|
extraParams += cmdData.InjectedParams.Count;
|
||||||
|
|
||||||
|
var paramObjs = new object[parameters.Length + extraParams];
|
||||||
|
|
||||||
|
var startAt = 0;
|
||||||
|
if (contextType != CommandContextType.Unspecified)
|
||||||
|
{
|
||||||
|
paramObjs[0] = ContextAdapterFactory.CreateNew(context, strings, svcs);
|
||||||
|
|
||||||
|
startAt = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < cmdData.InjectedParams.Count; i++)
|
||||||
|
{
|
||||||
|
var svc = svcProvider.GetService(cmdData.InjectedParams[i]);
|
||||||
|
if (svc is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Cannot inject a service of type {cmdData.InjectedParams[i]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
paramObjs[i + startAt] = svc;
|
||||||
|
|
||||||
|
svc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startAt += cmdData.InjectedParams.Count;
|
||||||
|
|
||||||
|
for (var i = 0; i < parameters.Length; i++)
|
||||||
|
paramObjs[startAt + i] = parameters[i];
|
||||||
|
|
||||||
|
return paramObjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private async Task<MarmaladeUnloadResult> InternalUnloadAsync(string name)
|
||||||
|
{
|
||||||
|
if (!_resolved.Remove(name, out var lsi))
|
||||||
|
return MarmaladeUnloadResult.NotLoaded;
|
||||||
|
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UnloadTypeReaders(lsi.TypeReaders);
|
||||||
|
|
||||||
|
foreach (var mi in lsi.ModuleInfos)
|
||||||
|
{
|
||||||
|
await _cmdService.RemoveModuleAsync(mi);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _behHandler.RemoveRangeAsync(lsi.Execs);
|
||||||
|
|
||||||
|
await DisposeCanaryInstances(lsi);
|
||||||
|
|
||||||
|
var lc = lsi.LoadContext;
|
||||||
|
|
||||||
|
// removing this line will prevent assembly from being unloaded quickly
|
||||||
|
// as this local variable will be held for a long time potentially
|
||||||
|
// due to how async works
|
||||||
|
lsi.Services = null!;
|
||||||
|
lsi = null;
|
||||||
|
|
||||||
|
_marmaladeConfig.RemoveLoadedMarmalade(name);
|
||||||
|
return UnloadInternal(lc)
|
||||||
|
? MarmaladeUnloadResult.Success
|
||||||
|
: MarmaladeUnloadResult.PossiblyUnable;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnloadTypeReaders(Dictionary<Type, TypeReader> valueTypeReaders)
|
||||||
|
{
|
||||||
|
foreach (var tr in valueTypeReaders)
|
||||||
|
{
|
||||||
|
_cmdService.TryRemoveTypeReader(tr.Key, false, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private async Task DisposeCanaryInstances(ResolvedMarmalade marmalade)
|
||||||
|
{
|
||||||
|
foreach (var si in marmalade.CanaryInfos)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await si.Instance.DisposeAsync();
|
||||||
|
foreach (var sub in si.Subcanaries)
|
||||||
|
{
|
||||||
|
await sub.Instance.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex,
|
||||||
|
"Failed cleanup of Canary {CanaryName}. This marmalade might not unload correctly",
|
||||||
|
si.Instance.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// marmalades = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private bool UnloadInternal(WeakReference<MarmaladeAssemblyLoadContext> lsi)
|
||||||
|
{
|
||||||
|
UnloadContext(lsi);
|
||||||
|
GcCleanup();
|
||||||
|
|
||||||
|
return !lsi.TryGetTarget(out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private void UnloadContext(WeakReference<MarmaladeAssemblyLoadContext> lsiLoadContext)
|
||||||
|
{
|
||||||
|
if (lsiLoadContext.TryGetTarget(out var ctx))
|
||||||
|
ctx.Unload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GcCleanup()
|
||||||
|
{
|
||||||
|
// cleanup
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.WaitForFullGCComplete();
|
||||||
|
GC.Collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Type _canaryType = typeof(Canary);
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private IServiceProvider LoadMarmaladeServicesInternal(Assembly a)
|
||||||
|
=> new ServiceCollection()
|
||||||
|
.Scan(x => x.FromAssemblies(a)
|
||||||
|
.AddClasses(static x => x.WithAttribute<svcAttribute>(x => x.Lifetime == Lifetime.Transient))
|
||||||
|
.AsSelfWithInterfaces()
|
||||||
|
.WithTransientLifetime()
|
||||||
|
.AddClasses(static x => x.WithAttribute<svcAttribute>(x => x.Lifetime == Lifetime.Singleton))
|
||||||
|
.AsSelfWithInterfaces()
|
||||||
|
.WithSingletonLifetime())
|
||||||
|
.BuildServiceProvider();
|
||||||
|
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
public IReadOnlyCollection<CanaryInfo> LoadCanariesFromAssembly(Assembly a, out IServiceProvider services)
|
||||||
|
{
|
||||||
|
var marmaladeServices = LoadMarmaladeServicesInternal(a);
|
||||||
|
services = new MarmaladeServiceProvider(_botServices, marmaladeServices);
|
||||||
|
|
||||||
|
// find all types in teh assembly
|
||||||
|
var types = a.GetExportedTypes();
|
||||||
|
// snek is always a public non abstract class
|
||||||
|
var classes = types.Where(static x => x.IsClass
|
||||||
|
&& (x.IsNestedPublic || x.IsPublic)
|
||||||
|
&& !x.IsAbstract
|
||||||
|
&& x.BaseType == _canaryType
|
||||||
|
&& (x.DeclaringType is null || x.DeclaringType.IsAssignableTo(_canaryType)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var topModules = new Dictionary<Type, CanaryInfo>();
|
||||||
|
|
||||||
|
foreach (var cl in classes)
|
||||||
|
{
|
||||||
|
if (cl.DeclaringType is not null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// get module data, and add it to the topModules dictionary
|
||||||
|
var module = GetModuleData(cl, services);
|
||||||
|
topModules.Add(cl, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var c in classes)
|
||||||
|
{
|
||||||
|
if (c.DeclaringType is not Type dt)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// if there is no top level module which this module is a child of
|
||||||
|
// just print a warning and skip it
|
||||||
|
if (!topModules.TryGetValue(dt, out var parentData))
|
||||||
|
{
|
||||||
|
Log.Warning("Can't load submodule {SubName} because parent module {Name} does not exist",
|
||||||
|
c.Name,
|
||||||
|
dt.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
GetModuleData(c, services, parentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return topModules.Values.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private CanaryInfo GetModuleData(Type type, IServiceProvider services, CanaryInfo? parentData = null)
|
||||||
|
{
|
||||||
|
var filters = type.GetCustomAttributes<FilterAttribute>(true)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var instance = (Canary)ActivatorUtilities.CreateInstance(services, type);
|
||||||
|
|
||||||
|
var module = new CanaryInfo(instance.Name,
|
||||||
|
parentData,
|
||||||
|
instance,
|
||||||
|
GetCommands(instance, type),
|
||||||
|
filters);
|
||||||
|
|
||||||
|
if (parentData is not null)
|
||||||
|
parentData.Subcanaries.Add(module);
|
||||||
|
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private IReadOnlyCollection<CanaryCommandData> GetCommands(Canary instance, Type type)
|
||||||
|
{
|
||||||
|
var methodInfos = type
|
||||||
|
.GetMethods(BindingFlags.Instance
|
||||||
|
| BindingFlags.DeclaredOnly
|
||||||
|
| BindingFlags.Public)
|
||||||
|
.Where(static x =>
|
||||||
|
{
|
||||||
|
if (x.GetCustomAttribute<cmdAttribute>(true) is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (x.ReturnType.IsGenericType)
|
||||||
|
{
|
||||||
|
var genericType = x.ReturnType.GetGenericTypeDefinition();
|
||||||
|
if (genericType == typeof(Task<>))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// if (genericType == typeof(ValueTask<>))
|
||||||
|
// return true;
|
||||||
|
|
||||||
|
Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}",
|
||||||
|
x.Name,
|
||||||
|
x.ReturnType);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var succ = x.ReturnType == typeof(Task)
|
||||||
|
|| x.ReturnType == typeof(ValueTask)
|
||||||
|
|| x.ReturnType == typeof(void);
|
||||||
|
|
||||||
|
if (!succ)
|
||||||
|
{
|
||||||
|
Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}",
|
||||||
|
x.Name,
|
||||||
|
x.ReturnType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return succ;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
var cmds = new List<CanaryCommandData>();
|
||||||
|
foreach (var method in methodInfos)
|
||||||
|
{
|
||||||
|
var filters = method.GetCustomAttributes<FilterAttribute>(true).ToArray();
|
||||||
|
var userAndBotPerms = method.GetCustomAttributes<MarmaladePermAttribute>(true)
|
||||||
|
.ToArray();
|
||||||
|
var prio = method.GetCustomAttribute<prioAttribute>(true)?.Priority ?? 0;
|
||||||
|
|
||||||
|
var paramInfos = method.GetParameters();
|
||||||
|
var cmdParams = new List<ParamData>();
|
||||||
|
var diParams = new List<Type>();
|
||||||
|
var cmdContext = CommandContextType.Unspecified;
|
||||||
|
var canInject = false;
|
||||||
|
for (var paramCounter = 0; paramCounter < paramInfos.Length; paramCounter++)
|
||||||
|
{
|
||||||
|
var pi = paramInfos[paramCounter];
|
||||||
|
|
||||||
|
var paramName = pi.Name ?? "unnamed";
|
||||||
|
var isContext = paramCounter == 0 && pi.ParameterType.IsAssignableTo(typeof(AnyContext));
|
||||||
|
|
||||||
|
var leftoverAttribute = pi.GetCustomAttribute<leftoverAttribute>(true);
|
||||||
|
var hasDefaultValue = pi.HasDefaultValue;
|
||||||
|
var defaultValue = pi.DefaultValue;
|
||||||
|
var isLeftover = leftoverAttribute != null;
|
||||||
|
var isParams = pi.GetCustomAttribute<ParamArrayAttribute>() is not null;
|
||||||
|
var paramType = pi.ParameterType;
|
||||||
|
var isInjected = pi.GetCustomAttribute<injectAttribute>(true) is not null;
|
||||||
|
|
||||||
|
if (isContext)
|
||||||
|
{
|
||||||
|
if (hasDefaultValue || leftoverAttribute != null || isParams)
|
||||||
|
throw new ArgumentException("IContext parameter cannot be optional, leftover, constant or params. " + GetErrorPath(method, pi));
|
||||||
|
|
||||||
|
if (paramCounter != 0)
|
||||||
|
throw new ArgumentException($"IContext parameter has to be first. {GetErrorPath(method, pi)}");
|
||||||
|
|
||||||
|
canInject = true;
|
||||||
|
|
||||||
|
if (paramType.IsAssignableTo(typeof(GuildContext)))
|
||||||
|
cmdContext = CommandContextType.Guild;
|
||||||
|
else if (paramType.IsAssignableTo(typeof(DmContext)))
|
||||||
|
cmdContext = CommandContextType.Dm;
|
||||||
|
else
|
||||||
|
cmdContext = CommandContextType.Any;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInjected)
|
||||||
|
{
|
||||||
|
if (!canInject && paramCounter != 0)
|
||||||
|
throw new ArgumentException($"Parameters marked as [Injected] have to come after IContext");
|
||||||
|
|
||||||
|
canInject = true;
|
||||||
|
|
||||||
|
diParams.Add(paramType);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
canInject = false;
|
||||||
|
|
||||||
|
if (isParams)
|
||||||
|
{
|
||||||
|
if (hasDefaultValue)
|
||||||
|
throw new NotSupportedException("Params can't have const values at the moment. "
|
||||||
|
+ GetErrorPath(method, pi));
|
||||||
|
// if it's params, it means it's an array, and i only need a parser for the actual type,
|
||||||
|
// as the parser will run on each array element, it can't be null
|
||||||
|
paramType = paramType.GetElementType()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// leftover can only be the last parameter.
|
||||||
|
if (isLeftover && paramCounter != paramInfos.Length - 1)
|
||||||
|
{
|
||||||
|
var path = GetErrorPath(method, pi);
|
||||||
|
Log.Error("Only one parameter can be marked [Leftover] and it has to be the last one. {Path} ",
|
||||||
|
path);
|
||||||
|
throw new ArgumentException("Leftover attribute error.");
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdParams.Add(new ParamData(paramType, paramName, hasDefaultValue, defaultValue, isLeftover, isParams));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var cmdAttribute = method.GetCustomAttribute<cmdAttribute>(true)!;
|
||||||
|
var aliases = cmdAttribute.Aliases;
|
||||||
|
if (aliases.Length == 0)
|
||||||
|
aliases = new[] { method.Name.ToLowerInvariant() };
|
||||||
|
|
||||||
|
cmds.Add(new(
|
||||||
|
aliases,
|
||||||
|
method,
|
||||||
|
instance,
|
||||||
|
filters,
|
||||||
|
userAndBotPerms,
|
||||||
|
cmdContext,
|
||||||
|
diParams,
|
||||||
|
cmdParams,
|
||||||
|
new(cmdAttribute.desc, cmdAttribute.args),
|
||||||
|
prio
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmds;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private string GetErrorPath(MethodInfo m, System.Reflection.ParameterInfo pi)
|
||||||
|
=> $@"Module: {m.DeclaringType?.Name}
|
||||||
|
Command: {m.Name}
|
||||||
|
ParamName: {pi.Name}
|
||||||
|
ParamType: {pi.ParameterType.Name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MarmaladeLoadResult
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
NotFound,
|
||||||
|
AlreadyLoaded,
|
||||||
|
Empty,
|
||||||
|
UnknownError,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MarmaladeUnloadResult
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
NotLoaded,
|
||||||
|
PossiblyUnable,
|
||||||
|
NotFound,
|
||||||
|
}
|
24
src/EllieBot/Common/Marmalade/MarmaladeServiceProvider.cs
Normal file
24
src/EllieBot/Common/Marmalade/MarmaladeServiceProvider.cs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public class MarmaladeServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _ellieServices;
|
||||||
|
private readonly IServiceProvider _marmaladeServices;
|
||||||
|
|
||||||
|
public MarmaladeServiceProvider(IServiceProvider ellieServices, IServiceProvider marmaladeServices)
|
||||||
|
{
|
||||||
|
_ellieServices = ellieServices;
|
||||||
|
_marmaladeServices = marmaladeServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
public object? GetService(Type serviceType)
|
||||||
|
{
|
||||||
|
if (!serviceType.Assembly.IsCollectible)
|
||||||
|
return _ellieServices.GetService(serviceType);
|
||||||
|
|
||||||
|
return _marmaladeServices.GetService(serviceType);
|
||||||
|
}
|
||||||
|
}
|
46
src/EllieBot/Common/Marmalade/Models/CanaryCommandData.cs
Normal file
46
src/EllieBot/Common/Marmalade/Models/CanaryCommandData.cs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
using Microsoft.VisualBasic;
|
||||||
|
using System.Reflection;
|
||||||
|
using CommandStrings = Ellie.Marmalade.CommandStrings;
|
||||||
|
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public sealed class CanaryCommandData
|
||||||
|
{
|
||||||
|
public CanaryCommandData(
|
||||||
|
IReadOnlyCollection<string> aliases,
|
||||||
|
MethodInfo methodInfo,
|
||||||
|
Canary module,
|
||||||
|
FilterAttribute[] filters,
|
||||||
|
MarmaladePermAttribute[] userAndBotPerms,
|
||||||
|
CommandContextType contextType,
|
||||||
|
IReadOnlyList<Type> injectedParams,
|
||||||
|
IReadOnlyList<ParamData> parameters,
|
||||||
|
CommandStrings strings,
|
||||||
|
int priority)
|
||||||
|
{
|
||||||
|
Aliases = aliases;
|
||||||
|
MethodInfo = methodInfo;
|
||||||
|
Module = module;
|
||||||
|
Filters = filters;
|
||||||
|
UserAndBotPerms = userAndBotPerms;
|
||||||
|
ContextType = contextType;
|
||||||
|
InjectedParams = injectedParams;
|
||||||
|
Parameters = parameters;
|
||||||
|
Priority = priority;
|
||||||
|
OptionalStrings = strings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MarmaladePermAttribute[] UserAndBotPerms { get; set; }
|
||||||
|
|
||||||
|
public CommandStrings OptionalStrings { get; set; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> Aliases { get; }
|
||||||
|
public MethodInfo MethodInfo { get; set; }
|
||||||
|
public Canary Module { get; set; }
|
||||||
|
public FilterAttribute[] Filters { get; set; }
|
||||||
|
public CommandContextType ContextType { get; }
|
||||||
|
public IReadOnlyList<Type> InjectedParams { get; }
|
||||||
|
public IReadOnlyList<ParamData> Parameters { get; }
|
||||||
|
public int Priority { get; }
|
||||||
|
|
||||||
|
}
|
11
src/EllieBot/Common/Marmalade/Models/CanaryData.cs
Normal file
11
src/EllieBot/Common/Marmalade/Models/CanaryData.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public sealed record CanaryInfo(
|
||||||
|
string Name,
|
||||||
|
CanaryInfo? Parent,
|
||||||
|
Canary Instance,
|
||||||
|
IReadOnlyCollection<CanaryCommandData> Commands,
|
||||||
|
IReadOnlyCollection<FilterAttribute> Filters)
|
||||||
|
{
|
||||||
|
public List<CanaryInfo> Subcanaries { get; set; } = new();
|
||||||
|
}
|
10
src/EllieBot/Common/Marmalade/Models/ParamData.cs
Normal file
10
src/EllieBot/Common/Marmalade/Models/ParamData.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public sealed record ParamData(
|
||||||
|
Type Type,
|
||||||
|
string Name,
|
||||||
|
bool IsOptional,
|
||||||
|
object? DefaultValue,
|
||||||
|
bool IsLeftover,
|
||||||
|
bool IsParams
|
||||||
|
);
|
14
src/EllieBot/Common/Marmalade/Models/ResolvedMarmalade.cs
Normal file
14
src/EllieBot/Common/Marmalade/Models/ResolvedMarmalade.cs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace Ellie.Marmalade;
|
||||||
|
|
||||||
|
public sealed record ResolvedMarmalade(
|
||||||
|
WeakReference<MarmaladeAssemblyLoadContext> LoadContext,
|
||||||
|
IImmutableList<ModuleInfo> ModuleInfos,
|
||||||
|
IImmutableList<CanaryInfo> CanaryInfos,
|
||||||
|
IMarmaladeStrings Strings,
|
||||||
|
Dictionary<Type, TypeReader> TypeReaders,
|
||||||
|
IReadOnlyCollection<ICustomBehavior> Execs)
|
||||||
|
{
|
||||||
|
public IServiceProvider Services { get; set; } = null!;
|
||||||
|
}
|
Loading…
Reference in a new issue