Medusa System Added

Read about the medusa system [here](https://nadekobot.readthedocs.io/en/latest/medusa/creating-a-medusa/)
This commit is contained in:
Kwoth
2022-04-16 12:44:46 +00:00
parent 3a516ab32a
commit 7cb15f5278
103 changed files with 3363 additions and 203 deletions

View File

@@ -1,23 +0,0 @@
#nullable disable
namespace NadekoBot;
public interface IEmbedBuilder
{
IEmbedBuilder WithDescription(string desc);
IEmbedBuilder WithTitle(string title);
IEmbedBuilder AddField(string title, object value, bool isInline = false);
IEmbedBuilder WithFooter(string text, string iconUrl = null);
IEmbedBuilder WithAuthor(string name, string iconUrl = null, string url = null);
IEmbedBuilder WithColor(EmbedColor color);
Embed Build();
IEmbedBuilder WithUrl(string url);
IEmbedBuilder WithImageUrl(string url);
IEmbedBuilder WithThumbnailUrl(string url);
}
public enum EmbedColor
{
Ok,
Pending,
Error
}

View File

@@ -0,0 +1,76 @@
#nullable enable
[DontAddToIocContainer]
public sealed class BehaviorAdapter : ICustomBehavior
{
private readonly WeakReference<Snek> _snekWr;
private readonly IMedusaStrings _strings;
private readonly IServiceProvider _services;
private readonly string _name;
// unused
public int Priority
=> 0;
public BehaviorAdapter(WeakReference<Snek> snekWr, IMedusaStrings strings, IServiceProvider services)
{
_snekWr = snekWr;
_strings = strings;
_services = services;
_name = snekWr.TryGetTarget(out var snek)
? $"snek/{snek.GetType().Name}"
: "unknown";
}
public async Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
{
if (!_snekWr.TryGetTarget(out var snek))
return false;
return await snek.ExecPreCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services),
moduleName,
command.Name);
}
public async Task<bool> ExecOnMessageAsync(IGuild? guild, IUserMessage msg)
{
if (!_snekWr.TryGetTarget(out var snek))
return false;
return await snek.ExecOnMessageAsync(guild, msg);
}
public async Task<string?> TransformInput(
IGuild guild,
IMessageChannel channel,
IUser user,
string input)
{
if (!_snekWr.TryGetTarget(out var snek))
return null;
return await snek.ExecInputTransformAsync(guild, channel, user, input);
}
public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg)
{
if (!_snekWr.TryGetTarget(out var snek))
return;
await snek.ExecOnNoCommandAsync(guild, msg);
}
public async ValueTask ExecPostCommandAsync(ICommandContext context, string moduleName, string commandName)
{
if (!_snekWr.TryGetTarget(out var snek))
return;
await snek.ExecPostCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services),
moduleName,
commandName);
}
public override string ToString()
=> _name;
}

View File

@@ -0,0 +1,7 @@
internal class ContextAdapterFactory
{
public static AnyContext CreateNew(ICommandContext context, IMedusaStrings strings, IServiceProvider services)
=> context.Guild is null
? new DmContextAdapter(context, strings, services)
: new GuildContextAdapter(context, strings, services);
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Extensions.DependencyInjection;
public sealed class DmContextAdapter : DmContext
{
public override IMedusaStrings Strings { get; }
public override IDMChannel Channel { get; }
public override IUserMessage Message { 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, IMedusaStrings 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;
_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.IsNullOrWhiteSpace(output))
return output;
return _botStrings.Value.GetText(key, cultureInfo, args);
}
}

View File

@@ -0,0 +1,51 @@
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 IMedusaStrings Strings { get; }
public override IGuild Guild { get; }
public override ITextChannel Channel { get; }
public override IUserMessage Message
=> _ctx.Message;
public override IGuildUser User { get; }
public override IEmbedBuilder Embed()
=> _ebs.Value.Create();
public GuildContextAdapter(ICommandContext ctx, IMedusaStrings 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;
_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);
}
}

View File

@@ -0,0 +1,32 @@
public sealed class ParamParserAdapter<T> : TypeReader
{
private readonly ParamParser<T> _parser;
private readonly IMedusaStrings _strings;
private readonly IServiceProvider _services;
public ParamParserAdapter(ParamParser<T> parser,
IMedusaStrings strings,
IServiceProvider services)
{
_parser = parser;
_strings = strings;
_services = services;
}
public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
ICommandContext context,
string input,
IServiceProvider services)
{
var medusaContext = ContextAdapterFactory.CreateNew(context,
_strings,
_services);
var result = await _parser.TryParseAsync(medusaContext, input);
if(result.IsSuccess)
return Discord.Commands.TypeReaderResult.FromSuccess(result.Data);
return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, "Invalid input");
}
}

View File

@@ -0,0 +1,27 @@
namespace Nadeko.Medusa;
/// <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
}

View File

@@ -0,0 +1,8 @@
namespace Nadeko.Medusa;
public interface IMedusaConfigService
{
IReadOnlyCollection<string> GetLoadedMedusae();
void AddLoadedMedusa(string name);
void RemoveLoadedMedusa(string name);
}

View File

@@ -0,0 +1,19 @@
using Cloneable;
using NadekoBot.Common.Yml;
namespace Nadeko.Medusa;
[Cloneable]
public sealed partial class MedusaConfig : ICloneable<MedusaConfig>
{
[Comment(@"DO NOT CHANGE")]
public int Version { get; set; } = 1;
[Comment("List of medusae automatically loaded at startup")]
public List<string> Loaded { get; set; }
public MedusaConfig()
{
Loaded = new();
}
}

View File

@@ -0,0 +1,43 @@
using NadekoBot.Common.Configs;
namespace Nadeko.Medusa;
public sealed class MedusaConfigService : ConfigServiceBase<MedusaConfig>, IMedusaConfigService
{
private const string FILE_PATH = "data/medusae/medusa.yml";
private static readonly TypedKey<MedusaConfig> _changeKey = new("config.medusa.updated");
public override string Name
=> "medusa";
public MedusaConfigService(
IConfigSeria serializer,
IPubSub pubSub)
: base(FILE_PATH, serializer, pubSub, _changeKey)
{
}
public IReadOnlyCollection<string> GetLoadedMedusae()
=> Data.Loaded.ToList();
public void AddLoadedMedusa(string name)
{
name = name.Trim().ToLowerInvariant();
ModifyConfig(conf =>
{
if(!conf.Loaded.Contains(name))
conf.Loaded.Add(name);
});
}
public void RemoveLoadedMedusa(string name)
{
name = name.Trim().ToLowerInvariant();
ModifyConfig(conf =>
{
conf.Loaded.Remove(name);
});
}
}

View File

@@ -0,0 +1,23 @@
using System.Globalization;
namespace Nadeko.Medusa;
public interface IMedusaLoaderService
{
Task<MedusaLoadResult> LoadMedusaAsync(string medusaName);
Task<MedusaUnloadResult> UnloadMedusaAsync(string medusaName);
string GetCommandDescription(string medusaName, string commandName, CultureInfo culture);
string[] GetCommandExampleArgs(string medusaName, string commandName, CultureInfo culture);
Task ReloadStrings();
IReadOnlyCollection<string> GetAllMedusae();
IReadOnlyCollection<MedusaStats> GetLoadedMedusae(CultureInfo? cultureInfo = null);
}
public sealed record MedusaStats(string Name,
string? Description,
IReadOnlyCollection<SnekStats> Sneks);
public sealed record SnekStats(string Name,
IReadOnlyCollection<SnekCommandStats> Commands);
public sealed record SnekCommandStats(string Name);

View File

@@ -0,0 +1,36 @@
using System.Reflection;
using System.Runtime.Loader;
namespace Nadeko.Medusa;
public sealed class MedusaAssemblyLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _depResolver;
public MedusaAssemblyLoadContext(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;
}
}

View File

@@ -0,0 +1,880 @@
using Discord.Commands.Builders;
using Microsoft.Extensions.DependencyInjection;
using NadekoBot.Common.ModuleBehaviors;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace Nadeko.Medusa;
// ReSharper disable RedundantAssignment
public sealed class MedusaLoaderService : IMedusaLoaderService, IReadyExecutor, INService
{
private readonly CommandService _cmdService;
private readonly IServiceProvider _botServices;
private readonly IBehaviorHandler _behHandler;
private readonly IPubSub _pubSub;
private readonly IMedusaConfigService _medusaConfig;
private readonly ConcurrentDictionary<string, ResolvedMedusa> _resolved = new();
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
private readonly TypedKey<string> _loadKey = new("medusa:load");
private readonly TypedKey<string> _unloadKey = new("medusa:unload");
private readonly TypedKey<bool> _stringsReload = new("medusa:reload_strings");
private const string BASE_DIR = "data/medusae";
public MedusaLoaderService(CommandService cmdService,
IServiceProvider botServices,
IBehaviorHandler behHandler,
IPubSub pubSub,
IMedusaConfigService medusaConfig)
{
_cmdService = cmdService;
_botServices = botServices;
_behHandler = behHandler;
_pubSub = pubSub;
_medusaConfig = medusaConfig;
// 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> GetAllMedusae()
{
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<MedusaStats> GetLoadedMedusae(CultureInfo? culture)
{
var toReturn = new List<MedusaStats>(_resolved.Count);
foreach (var (name, resolvedData) in _resolved)
{
var sneks = new List<SnekStats>(resolvedData.SnekInfos.Count);
foreach (var snekInfos in resolvedData.SnekInfos.Concat(resolvedData.SnekInfos.SelectMany(x => x.Subsneks)))
{
var commands = new List<SnekCommandStats>();
foreach (var command in snekInfos.Commands)
{
commands.Add(new SnekCommandStats(command.Aliases.First()));
}
sneks.Add(new SnekStats(snekInfos.Name, commands));
}
toReturn.Add(new MedusaStats(name, resolvedData.Strings.GetDescription(culture), sneks));
}
return toReturn;
}
public async Task OnReadyAsync()
{
foreach (var name in _medusaConfig.GetLoadedMedusae())
{
var result = await InternalLoadAsync(name);
if(result != MedusaLoadResult.Success)
Log.Warning("Unable to load '{MedusaName}' medusa", name);
else
Log.Warning("Loaded medusa '{MedusaName}'", name);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public async Task<MedusaLoadResult> LoadMedusaAsync(string medusaName)
{
// try loading on this shard first to see if it works
var res = await InternalLoadAsync(medusaName);
if (res == MedusaLoadResult.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, medusaName);
}
return res;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public async Task<MedusaUnloadResult> UnloadMedusaAsync(string medusaName)
{
var res = await InternalUnloadAsync(medusaName);
if (res == MedusaUnloadResult.Success)
{
await _pubSub.Pub(_unloadKey, medusaName);
}
return res;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public string[] GetCommandExampleArgs(string medusaName, string commandName, CultureInfo culture)
{
if (!_resolved.TryGetValue(medusaName, out var data))
return Array.Empty<string>();
return data.Strings.GetCommandStrings(commandName, culture).Args
?? data.SnekInfos
.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 medusaName, string commandName, CultureInfo culture)
{
if (!_resolved.TryGetValue(medusaName, out var data))
return string.Empty;
return data.Strings.GetCommandStrings(commandName, culture).Desc
?? data.SnekInfos
.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<MedusaLoadResult> InternalLoadAsync(string name)
{
if (_resolved.ContainsKey(name))
return MedusaLoadResult.AlreadyLoaded;
var safeName = Uri.EscapeDataString(name);
name = name.ToLowerInvariant();
await _lock.WaitAsync();
try
{
var success = LoadAssemblyInternal(safeName,
out var ctx,
out var snekData,
out var services,
out var strings,
out var typeReaders);
if (success)
{
var moduleInfos = new List<ModuleInfo>();
// todo uncomment
LoadTypeReadersInternal(typeReaders);
foreach (var point in snekData)
{
try
{
// initialize snek and subsneks
await point.Instance.InitializeAsync();
foreach (var sub in point.Subsneks)
{
await sub.Instance.InitializeAsync();
}
var module = await LoadModuleInternalAsync(name, point, strings, services);
moduleInfos.Add(module);
}
catch (Exception ex)
{
Log.Warning(ex,
"Error loading snek {SnekName}",
point.Name);
}
}
var execs = GetExecsInternal(snekData, strings, services);
await _behHandler.AddRangeAsync(execs);
_resolved[name] = new(LoadContext: ctx,
ModuleInfos: moduleInfos.ToImmutableArray(),
SnekInfos: snekData.ToImmutableArray(),
strings,
typeReaders,
execs)
{
Services = services
};
services = null;
_medusaConfig.AddLoadedMedusa(safeName);
return MedusaLoadResult.Success;
}
return MedusaLoadResult.Empty;
}
catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException)
{
return MedusaLoadResult.NotFound;
}
catch (Exception ex)
{
Log.Error(ex, "An error occurred loading a medusa");
return MedusaLoadResult.UnknownError;
}
finally
{
_lock.Release();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private IReadOnlyCollection<ICustomBehavior> GetExecsInternal(IReadOnlyCollection<SnekInfo> snekData, IMedusaStrings strings, IServiceProvider services)
{
var behs = new List<ICustomBehavior>();
foreach (var snek in snekData)
{
behs.Add(new BehaviorAdapter(new(snek.Instance), strings, services));
foreach (var sub in snek.Subsneks)
{
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 medusa
foreach (var toRemove in notAddedTypeReaders)
{
typeReaders.Remove(toRemove);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private bool LoadAssemblyInternal(
string safeName,
[NotNullWhen(true)] out WeakReference<MedusaAssemblyLoadContext>? ctxWr,
[NotNullWhen(true)] out IReadOnlyCollection<SnekInfo>? snekData,
out IServiceProvider services,
out IMedusaStrings strings,
out Dictionary<Type, TypeReader> typeReaders)
{
ctxWr = null;
snekData = null;
var path = $"{BASE_DIR}/{safeName}/{safeName}.dll";
strings = MedusaStrings.CreateDefault($"{BASE_DIR}/{safeName}");
var ctx = new MedusaAssemblyLoadContext(Path.GetDirectoryName(path)!);
var a = ctx.LoadFromAssemblyPath(Path.GetFullPath(path));
var sis = LoadSneksFromAssembly(a, out services);
typeReaders = LoadTypeReadersFromAssembly(a, strings, services);
if (sis.Count == 0)
{
return false;
}
ctxWr = new(ctx);
snekData = sis;
return true;
}
private static readonly Type _paramParserType = typeof(ParamParser<>);
[MethodImpl(MethodImplOptions.NoInlining)]
private Dictionary<Type, TypeReader> LoadTypeReadersFromAssembly(
Assembly assembly,
IMedusaStrings 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 medusaName, SnekInfo snekInfo, IMedusaStrings strings, IServiceProvider services)
{
var module = await _cmdService.CreateModuleAsync(snekInfo.Instance.Prefix,
CreateModuleFactory(medusaName, snekInfo, strings, services));
return module;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private Action<ModuleBuilder> CreateModuleFactory(
string medusaName,
SnekInfo snekInfo,
IMedusaStrings strings,
IServiceProvider medusaServices)
=> mb =>
{
var m = mb.WithName(snekInfo.Name);
foreach (var cmd in snekInfo.Commands)
{
m.AddCommand(cmd.Aliases.First(),
CreateCallback(cmd.ContextType,
new(snekInfo),
new(cmd),
new(medusaServices),
strings),
CreateCommandFactory(medusaName, cmd));
}
foreach (var subInfo in snekInfo.Subsneks)
m.AddModule(subInfo.Instance.Prefix, CreateModuleFactory(medusaName, subInfo, strings, medusaServices));
};
private static readonly RequireContextAttribute _reqGuild = new RequireContextAttribute(ContextType.Guild);
private static readonly RequireContextAttribute _reqDm = new RequireContextAttribute(ContextType.DM);
private Action<CommandBuilder> CreateCommandFactory(string medusaName, SnekCommandData cmd)
=> (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);
cb.WithPriority(cmd.Priority);
// using summary to save method name
// method name is used to retrieve desc/usages
cb.WithRemarks($"medusa///{medusaName}");
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);
};
[MethodImpl(MethodImplOptions.NoInlining)]
private Func<ICommandContext, object[], IServiceProvider, CommandInfo, Task> CreateCallback(
CommandContextType contextType,
WeakReference<SnekInfo> snekDataWr,
WeakReference<SnekCommandData> snekCommandDataWr,
WeakReference<IServiceProvider> medusaServicesWr,
IMedusaStrings strings)
=> async (context, parameters, svcs, _) =>
{
if (!snekCommandDataWr.TryGetTarget(out var cmdData)
|| !snekDataWr.TryGetTarget(out var snekData)
|| !medusaServicesWr.TryGetTarget(out var medusaServices))
{
Log.Warning("Attempted to run an unloaded snek's command");
return;
}
var paramObjs = ParamObjs(contextType, cmdData, parameters, context, svcs, medusaServices, strings);
try
{
var methodInfo = cmdData.MethodInfo;
if (methodInfo.ReturnType == typeof(Task)
|| (methodInfo.ReturnType.IsGenericType
&& methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)))
{
await (Task)methodInfo.Invoke(snekData.Instance, paramObjs)!;
}
else if (methodInfo.ReturnType == typeof(ValueTask))
{
await ((ValueTask)methodInfo.Invoke(snekData.Instance, paramObjs)!).AsTask();
}
else // if (methodInfo.ReturnType == typeof(void))
{
methodInfo.Invoke(snekData.Instance, paramObjs);
}
}
finally
{
paramObjs = null;
cmdData = null;
snekData = null;
medusaServices = null;
}
};
[MethodImpl(MethodImplOptions.NoInlining)]
private static object[] ParamObjs(
CommandContextType contextType,
SnekCommandData cmdData,
object[] parameters,
ICommandContext context,
IServiceProvider svcs,
IServiceProvider svcProvider,
IMedusaStrings 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<MedusaUnloadResult> InternalUnloadAsync(string name)
{
name = name.ToLowerInvariant();
if (!_resolved.Remove(name, out var lsi))
return MedusaUnloadResult.NotLoaded;
await _lock.WaitAsync();
try
{
UnloadTypeReaders(lsi.TypeReaders);
foreach (var mi in lsi.ModuleInfos)
{
await _cmdService.RemoveModuleAsync(mi);
}
await _behHandler.RemoveRangeAsync(lsi.Execs);
await DisposeSnekInstances(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;
_medusaConfig.RemoveLoadedMedusa(name);
return UnloadInternal(lc)
? MedusaUnloadResult.Success
: MedusaUnloadResult.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 DisposeSnekInstances(ResolvedMedusa medusa)
{
foreach (var si in medusa.SnekInfos)
{
try
{
await si.Instance.DisposeAsync();
foreach (var sub in si.Subsneks)
{
await sub.Instance.DisposeAsync();
}
}
catch (Exception ex)
{
Log.Warning(ex,
"Failed cleanup of Snek {SnekName}. This medusa might not unload correctly",
si.Instance.Name);
}
}
// medusae = null;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private bool UnloadInternal(WeakReference<MedusaAssemblyLoadContext> lsi)
{
UnloadContext(lsi);
GcCleanup();
return !lsi.TryGetTarget(out _);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void UnloadContext(WeakReference<MedusaAssemblyLoadContext> 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 _snekType = typeof(Snek);
[MethodImpl(MethodImplOptions.NoInlining)]
private IServiceProvider LoadMedusaServicesInternal(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<SnekInfo> LoadSneksFromAssembly(Assembly a, out IServiceProvider services)
{
var medusaServices = LoadMedusaServicesInternal(a);
services = new MedusaServiceProvider(_botServices, medusaServices);
// 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 == _snekType
&& (x.DeclaringType is null || x.DeclaringType.IsAssignableTo(_snekType)))
.ToList();
var topModules = new Dictionary<Type, SnekInfo>();
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 SnekInfo GetModuleData(Type type, IServiceProvider services, SnekInfo? parentData = null)
{
var filters = type.GetCustomAttributes<FilterAttribute>(true)
.ToArray();
var instance = (Snek)ActivatorUtilities.CreateInstance(services, type);
var module = new SnekInfo(instance.Name,
parentData,
instance,
GetCommands(instance, type),
filters);
if (parentData is not null)
parentData.Subsneks.Add(module);
return module;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private IReadOnlyCollection<SnekCommandData> GetCommands(Snek 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<SnekCommandData>();
foreach (var method in methodInfos)
{
var filters = method.GetCustomAttributes<FilterAttribute>().ToArray();
var prio = method.GetCustomAttribute<prioAttribute>()?.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<Nadeko.Snake.leftoverAttribute>(true);
var hasDefaultValue = pi.HasDefaultValue;
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, isLeftover, isParams));
}
var cmdAttribute = method.GetCustomAttribute<cmdAttribute>()!;
var aliases = cmdAttribute.Aliases;
if (aliases.Length == 0)
aliases = new[] { method.Name.ToLowerInvariant() };
cmds.Add(new(
aliases,
method,
instance,
filters,
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 MedusaLoadResult
{
Success,
NotFound,
AlreadyLoaded,
Empty,
UnknownError,
}
public enum MedusaUnloadResult
{
Success,
NotLoaded,
PossiblyUnable,
NotFound,
}

View File

@@ -0,0 +1,24 @@
using System.Runtime.CompilerServices;
namespace Nadeko.Medusa;
public class MedusaServiceProvider : IServiceProvider
{
private readonly IServiceProvider _nadekoServices;
private readonly IServiceProvider _medusaServices;
public MedusaServiceProvider(IServiceProvider nadekoServices, IServiceProvider medusaServices)
{
_nadekoServices = nadekoServices;
_medusaServices = medusaServices;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public object? GetService(Type serviceType)
{
if (!serviceType.Assembly.IsCollectible)
return _nadekoServices.GetService(serviceType);
return _medusaServices.GetService(serviceType);
}
}

View File

@@ -0,0 +1,9 @@
namespace Nadeko.Medusa;
public sealed record ParamData(
Type Type,
string Name,
bool IsOptional,
bool IsLeftover,
bool IsParams
);

View File

@@ -0,0 +1,14 @@
using System.Collections.Immutable;
namespace Nadeko.Medusa;
public sealed record ResolvedMedusa(
WeakReference<MedusaAssemblyLoadContext> LoadContext,
IImmutableList<ModuleInfo> ModuleInfos,
IImmutableList<SnekInfo> SnekInfos,
IMedusaStrings Strings,
Dictionary<Type, TypeReader> TypeReaders,
IReadOnlyCollection<ICustomBehavior> Execs)
{
public IServiceProvider Services { get; set; } = null!;
}

View File

@@ -0,0 +1,41 @@
using Microsoft.VisualBasic;
using System.Reflection;
using CommandStrings = Nadeko.Snake.CommandStrings;
namespace Nadeko.Medusa;
public sealed class SnekCommandData
{
public SnekCommandData(
IReadOnlyCollection<string> aliases,
MethodInfo methodInfo,
Snek module,
FilterAttribute[] filters,
CommandContextType contextType,
IReadOnlyList<Type> injectedParams,
IReadOnlyList<ParamData> parameters,
CommandStrings strings,
int priority)
{
Aliases = aliases;
MethodInfo = methodInfo;
Module = module;
Filters = filters;
ContextType = contextType;
InjectedParams = injectedParams;
Parameters = parameters;
Priority = priority;
OptionalStrings = strings;
}
public CommandStrings OptionalStrings { get; set; }
public IReadOnlyCollection<string> Aliases { get; }
public MethodInfo MethodInfo { get; set; }
public Snek 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; }
}

View File

@@ -0,0 +1,11 @@
namespace Nadeko.Medusa;
public sealed record SnekInfo(
string Name,
SnekInfo? Parent,
Snek Instance,
IReadOnlyCollection<SnekCommandData> Commands,
IReadOnlyCollection<FilterAttribute> Filters)
{
public List<SnekInfo> Subsneks { get; set; } = new();
}

View File

@@ -1,10 +0,0 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// Implemented by modules which block execution before anything is executed
/// </summary>
public interface IEarlyBehavior
{
int Priority { get; }
Task<bool> RunBehavior(IGuild guild, IUserMessage msg);
}

View File

@@ -0,0 +1,19 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// Executed if no command was found for this message
/// </summary>
public interface IExecNoCommand
{
/// <summary>
/// Executed at the end of the lifecycle if no command was found
/// <see cref="IExecOnMessage"/> →
/// <see cref="IInputTransformer"/> →
/// <see cref="IExecPreCommand"/> →
/// [<see cref="IExecPostCommand"/> | *<see cref="IExecNoCommand"/>*]
/// </summary>
/// <param name="guild"></param>
/// <param name="msg"></param>
/// <returns>A task representing completion</returns>
Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg);
}

View File

@@ -0,0 +1,21 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// Implemented by modules to handle non-bot messages received
/// </summary>
public interface IExecOnMessage
{
int Priority { get; }
/// <summary>
/// Ran after a non-bot message was received
/// *<see cref="IExecOnMessage"/>* →
/// <see cref="IInputTransformer"/> →
/// <see cref="IExecPreCommand"/> →
/// [<see cref="IExecPostCommand"/> | <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="guild">Guild where the message was sent</param>
/// <param name="msg">The message that was received</param>
/// <returns>Whether further processing of this message should be blocked</returns>
Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg);
}

View File

@@ -0,0 +1,22 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// This interface's method is executed after the command successfully finished execution.
/// ***There is no support for this method in NadekoBot services.***
/// It is only meant to be used in medusa system
/// </summary>
public interface IExecPostCommand
{
/// <summary>
/// Executed after a command was successfully executed
/// <see cref="IExecOnMessage"/> →
/// <see cref="IInputTransformer"/> →
/// <see cref="IExecPreCommand"/> →
/// [*<see cref="IExecPostCommand"/>* | <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="ctx">Command context</param>
/// <param name="moduleName">Module name</param>
/// <param name="commandName">Command name</param>
/// <returns>A task representing completion</returns>
ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName);
}

View File

@@ -0,0 +1,25 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// This interface's method is executed after a command was found but before it was executed.
/// Able to block further processing of a command
/// </summary>
public interface IExecPreCommand
{
public int Priority { get; }
/// <summary>
/// <para>
/// Ran after a command was found but before execution.
/// </para>
/// <see cref="IExecOnMessage"/> →
/// <see cref="IInputTransformer"/> →
/// *<see cref="IExecPreCommand"/>* →
/// [<see cref="IExecPostCommand"/> | <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="context">Command context</param>
/// <param name="moduleName">Name of the module</param>
/// <param name="command">Command info</param>
/// <returns>Whether further processing of the command is blocked</returns>
Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command);
}

View File

@@ -1,8 +1,23 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// Implemented by services which may transform input before a command is searched for
/// </summary>
public interface IInputTransformer
{
Task<string> TransformInput(
/// <summary>
/// Ran after a non-bot message was received
/// <see cref="IExecOnMessage"/> ->
/// *<see cref="IInputTransformer"/>* ->
/// <see cref="IExecPreCommand"/> ->
/// [<see cref="IExecPostCommand"/> OR <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="guild">Guild</param>
/// <param name="channel">Channel in which the message was sent</param>
/// <param name="user">User who sent the message</param>
/// <param name="input">Content of the message</param>
/// <returns>New input, if any, otherwise null</returns>
Task<string?> TransformInput(
IGuild guild,
IMessageChannel channel,
IUser user,

View File

@@ -1,8 +0,0 @@
namespace NadekoBot.Common.ModuleBehaviors;
public interface ILateBlocker
{
public int Priority { get; }
Task<bool> TryBlockLate(ICommandContext context, string moduleName, CommandInfo command);
}

View File

@@ -1,9 +0,0 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// Last thing to be executed, won't stop further executions
/// </summary>
public interface ILateExecutor
{
Task LateExecute(IGuild guild, IUserMessage msg);
}

View File

@@ -18,4 +18,13 @@ public sealed class NoPublicBotAttribute : PreconditionAttribute
return Task.FromResult(PreconditionResult.FromSuccess());
#endif
}
}
/// <summary>
/// Classed marked with this attribute will not be added to the service provider
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class DontAddToIocContainerAttribute : Attribute
{
}

View File

@@ -1,4 +1,5 @@
#nullable disable
namespace NadekoBot.Common.TypeReaders;
[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)]