diff --git a/src/EllieBot/Common/Attributes/Aliases.cs b/src/EllieBot/Common/Attributes/Aliases.cs new file mode 100644 index 0000000..14df34a --- /dev/null +++ b/src/EllieBot/Common/Attributes/Aliases.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class AliasesAttribute : AliasAttribute +{ + public AliasesAttribute([CallerMemberName] string memberName = "") + : base(CommandNameLoadHelper.GetAliasesFor(memberName)) + { + } +} diff --git a/src/EllieBot/Common/Attributes/CommandNameLoadHelper.cs b/src/EllieBot/Common/Attributes/CommandNameLoadHelper.cs new file mode 100644 index 0000000..e77533d --- /dev/null +++ b/src/EllieBot/Common/Attributes/CommandNameLoadHelper.cs @@ -0,0 +1,31 @@ +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Attributes; + +public static class CommandNameLoadHelper +{ + private static readonly IDeserializer _deserializer = new Deserializer(); + + private static readonly Lazy> _lazyCommandAliases + = new(() => LoadAliases()); + + public static Dictionary LoadAliases(string aliasesFilePath = "data/aliases.yml") + { + var text = File.ReadAllText(aliasesFilePath); + return _deserializer.Deserialize>(text); + } + + public static string[] GetAliasesFor(string methodName) + => _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1 + ? aliases.Skip(1).ToArray() + : Array.Empty(); + + public static string GetCommandNameFor(string methodName) + { + methodName = methodName.ToLowerInvariant(); + var toReturn = _lazyCommandAliases.Value.TryGetValue(methodName, out var aliases) && aliases.Length > 0 + ? aliases[0] + : methodName; + return toReturn; + } +} diff --git a/src/EllieBot/Common/Attributes/DontAddToIocContainerAttribute.cs b/src/EllieBot/Common/Attributes/DontAddToIocContainerAttribute.cs new file mode 100644 index 0000000..666ebbe --- /dev/null +++ b/src/EllieBot/Common/Attributes/DontAddToIocContainerAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Common; + +/// +/// Classed marked with this attribute will not be added to the service provider +/// +[AttributeUsage(AttributeTargets.Class)] +public class DontAddToIocContainerAttribute : Attribute +{ + +} diff --git a/src/EllieBot/Common/Attributes/EllieCommand.cs b/src/EllieBot/Common/Attributes/EllieCommand.cs new file mode 100644 index 0000000..109c1c5 --- /dev/null +++ b/src/EllieBot/Common/Attributes/EllieCommand.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class CmdAttribute : CommandAttribute +{ + public string MethodName { get; } + + public CmdAttribute([CallerMemberName] string memberName = "") + : base(CommandNameLoadHelper.GetCommandNameFor(memberName)) + { + MethodName = memberName.ToLowerInvariant(); + Aliases = CommandNameLoadHelper.GetAliasesFor(memberName); + Remarks = memberName.ToLowerInvariant(); + Summary = memberName.ToLowerInvariant(); + } +} diff --git a/src/EllieBot/Common/Attributes/EllieOptions.cs b/src/EllieBot/Common/Attributes/EllieOptions.cs new file mode 100644 index 0000000..32f91df --- /dev/null +++ b/src/EllieBot/Common/Attributes/EllieOptions.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class EllieOptionsAttribute : Attribute +{ + public Type OptionType { get; set; } + + public EllieOptionsAttribute(Type t) + => OptionType = t; +} \ No newline at end of file diff --git a/src/EllieBot/Common/Attributes/NoPublicBotPrecondition.cs b/src/EllieBot/Common/Attributes/NoPublicBotPrecondition.cs new file mode 100644 index 0000000..d380a92 --- /dev/null +++ b/src/EllieBot/Common/Attributes/NoPublicBotPrecondition.cs @@ -0,0 +1,38 @@ +#nullable disable +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Common; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +[SuppressMessage("Style", "IDE0022:Use expression body for methods")] +public sealed class NoPublicBotAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { +#if GLOBAL_ELLIE + return Task.FromResult(PreconditionResult.FromError("Not available on the public bot. To learn how to selfhost a private bot, click [here](https://docs.elliebot.net/v4/).")); +#else + return Task.FromResult(PreconditionResult.FromSuccess()); +#endif + } +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +[SuppressMessage("Style", "IDE0022:Use expression body for methods")] +public sealed class OnlyPublicBotAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { +#if GLOBAL_ELLIE || DEBUG + return Task.FromResult(PreconditionResult.FromSuccess()); +#else + return Task.FromResult(PreconditionResult.FromError("Only available on the public bot.")); +#endif + } +} diff --git a/src/EllieBot/Common/Attributes/OwnerOnlyAttribute.cs b/src/EllieBot/Common/Attributes/OwnerOnlyAttribute.cs new file mode 100644 index 0000000..7aa9317 --- /dev/null +++ b/src/EllieBot/Common/Attributes/OwnerOnlyAttribute.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class OwnerOnlyAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var creds = services.GetRequiredService().GetCreds(); + + return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id + ? PreconditionResult.FromSuccess() + : PreconditionResult.FromError("Not owner")); + } +} diff --git a/src/EllieBot/Common/Attributes/Ratelimit.cs b/src/EllieBot/Common/Attributes/Ratelimit.cs new file mode 100644 index 0000000..9a8c887 --- /dev/null +++ b/src/EllieBot/Common/Attributes/Ratelimit.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class RatelimitAttribute : PreconditionAttribute +{ + public int Seconds { get; } + + public RatelimitAttribute(int seconds) + { + if (seconds <= 0) + throw new ArgumentOutOfRangeException(nameof(seconds)); + + Seconds = seconds; + } + + public override async Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + if (Seconds == 0) + return PreconditionResult.FromSuccess(); + + var cache = services.GetRequiredService(); + var rem = await cache.GetRatelimitAsync( + new($"precondition:{context.User.Id}:{command.Name}"), + Seconds.Seconds()); + + if (rem is null) + return PreconditionResult.FromSuccess(); + + var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s."; + + return PreconditionResult.FromError(msgContent); + } +} diff --git a/src/EllieBot/Common/Attributes/UserPerm.cs b/src/EllieBot/Common/Attributes/UserPerm.cs new file mode 100644 index 0000000..dd2e7b5 --- /dev/null +++ b/src/EllieBot/Common/Attributes/UserPerm.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Modules.Administration.Services; + +namespace Discord; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class UserPermAttribute : RequireUserPermissionAttribute +{ + public UserPermAttribute(GuildPerm permission) + : base(permission) + { + } + + public UserPermAttribute(ChannelPerm permission) + : base(permission) + { + } + + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var permService = services.GetRequiredService(); + if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _)) + return Task.FromResult(PreconditionResult.FromSuccess()); + + return base.CheckPermissionsAsync(context, command, services); + } +} \ No newline at end of file diff --git a/src/EllieBot/Common/Cache/BotCacheExtensions.cs b/src/EllieBot/Common/Cache/BotCacheExtensions.cs new file mode 100644 index 0000000..41c6066 --- /dev/null +++ b/src/EllieBot/Common/Cache/BotCacheExtensions.cs @@ -0,0 +1,46 @@ +using OneOf; +using OneOf.Types; + +namespace EllieBot.Common; + +public static class BotCacheExtensions +{ + public static async ValueTask GetOrDefaultAsync(this IBotCache cache, TypedKey key) + { + var result = await cache.GetAsync(key); + if (result.TryGetValue(out var val)) + return val; + + return default; + } + + private static TypedKey GetImgKey(Uri uri) + => new($"image:{uri}"); + + public static ValueTask SetImageDataAsync(this IBotCache c, string key, byte[] data) + => c.SetImageDataAsync(new Uri(key), data); + public static async ValueTask SetImageDataAsync(this IBotCache c, Uri key, byte[] data) + => await c.AddAsync(GetImgKey(key), data, expiry: TimeSpan.FromHours(48)); + + public static async ValueTask> GetImageDataAsync(this IBotCache c, Uri key) + => await c.GetAsync(GetImgKey(key)); + + public static async Task GetRatelimitAsync( + this IBotCache c, + TypedKey key, + TimeSpan length) + { + var now = DateTime.UtcNow; + var nowB = now.ToBinary(); + + var cachedValue = await c.GetOrAddAsync(key, + () => Task.FromResult(now.ToBinary()), + expiry: length); + + if (cachedValue == nowB) + return null; + + var diff = now - DateTime.FromBinary(cachedValue); + return length - diff; + } +} diff --git a/src/EllieBot/Common/Cache/IBotCache.cs b/src/EllieBot/Common/Cache/IBotCache.cs new file mode 100644 index 0000000..e1ea2c9 --- /dev/null +++ b/src/EllieBot/Common/Cache/IBotCache.cs @@ -0,0 +1,47 @@ +using OneOf; +using OneOf.Types; + +namespace EllieBot.Common; + +public interface IBotCache +{ + /// + /// Adds an item to the cache + /// + /// Key to add + /// Value to add to the cache + /// Optional expiry + /// Whether old value should be overwritten + /// Type of the value + /// Returns whether add was sucessful. Always true unless ovewrite = false + ValueTask AddAsync(TypedKey key, T value, TimeSpan? expiry = null, bool overwrite = true); + + /// + /// Get an element from the cache + /// + /// Key + /// Type of the value + /// Either a value or + ValueTask> GetAsync(TypedKey key); + + /// + /// Remove a key from the cache + /// + /// Key to remove + /// Type of the value + /// Whether there was item + ValueTask RemoveAsync(TypedKey key); + + /// + /// Get the key if it exists or add a new one + /// + /// Key to get and potentially add + /// Value creation factory + /// Optional expiry + /// Type of the value + /// The retrieved or newly added value + ValueTask GetOrAddAsync( + TypedKey key, + Func> createFactory, + TimeSpan? expiry = null); +} diff --git a/src/EllieBot/Common/Cache/MemoryBotCache.cs b/src/EllieBot/Common/Cache/MemoryBotCache.cs new file mode 100644 index 0000000..d0b7e68 --- /dev/null +++ b/src/EllieBot/Common/Cache/MemoryBotCache.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Caching.Memory; +using OneOf; +using OneOf.Types; + +// ReSharper disable InconsistentlySynchronizedField + +namespace EllieBot.Common; + +public sealed class MemoryBotCache : IBotCache +{ + // needed for overwrites and Delete return value + private readonly object _cacheLock = new object(); + private readonly MemoryCache _cache; + + public MemoryBotCache() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + } + + public ValueTask AddAsync(TypedKey key, T value, TimeSpan? expiry = null, bool overwrite = true) + { + if (overwrite) + { + using var item = _cache.CreateEntry(key.Key); + item.Value = value; + item.AbsoluteExpirationRelativeToNow = expiry; + return new(true); + } + + lock (_cacheLock) + { + if (_cache.TryGetValue(key.Key, out var old) && old is not null) + return new(false); + + using var item = _cache.CreateEntry(key.Key); + item.Value = value; + item.AbsoluteExpirationRelativeToNow = expiry; + return new(true); + } + } + + public async ValueTask GetOrAddAsync( + TypedKey key, + Func> createFactory, + TimeSpan? expiry = null) + => await _cache.GetOrCreateAsync(key.Key, + async ce => + { + ce.AbsoluteExpirationRelativeToNow = expiry; + var val = await createFactory(); + return val; + }); + + public ValueTask> GetAsync(TypedKey key) + { + if (!_cache.TryGetValue(key.Key, out var val) || val is null) + return new(new None()); + + return new((T)val); + } + + public ValueTask RemoveAsync(TypedKey key) + { + lock (_cacheLock) + { + var toReturn = _cache.TryGetValue(key.Key, out var old) && old is not null; + _cache.Remove(key.Key); + return new(toReturn); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Common/Cache/RedisBotCache.cs b/src/EllieBot/Common/Cache/RedisBotCache.cs new file mode 100644 index 0000000..8de00fe --- /dev/null +++ b/src/EllieBot/Common/Cache/RedisBotCache.cs @@ -0,0 +1,119 @@ +using OneOf; +using OneOf.Types; +using StackExchange.Redis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Common; + +public sealed class RedisBotCache : IBotCache +{ + private static readonly Type[] _supportedTypes = new[] + { + typeof(bool), typeof(int), typeof(uint), typeof(long), + typeof(ulong), typeof(float), typeof(double), + typeof(string), typeof(byte[]), typeof(ReadOnlyMemory), typeof(Memory), + typeof(RedisValue), + }; + + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + AllowTrailingCommas = true, + IgnoreReadOnlyProperties = false, + }; + private readonly ConnectionMultiplexer _conn; + + public RedisBotCache(ConnectionMultiplexer conn) + { + _conn = conn; + } + + public async ValueTask AddAsync(TypedKey key, T value, TimeSpan? expiry = null, bool overwrite = true) + { + // if a null value is passed, remove the key + if (value is null) + { + await RemoveAsync(key); + return false; + } + + var db = _conn.GetDatabase(); + RedisValue val = IsSupportedType(typeof(T)) + ? RedisValue.Unbox(value) + : JsonSerializer.Serialize(value, _opts); + + var success = await db.StringSetAsync(key.Key, + val, + expiry: expiry, + when: overwrite ? When.Always : When.NotExists); + + return success; + } + + public bool IsSupportedType(Type type) + { + if (type.IsGenericType) + { + var typeDef = type.GetGenericTypeDefinition(); + if (typeDef == typeof(Nullable<>)) + return IsSupportedType(type.GenericTypeArguments[0]); + } + + foreach (var t in _supportedTypes) + { + if (type == t) + return true; + } + + return false; + } + + public async ValueTask> GetAsync(TypedKey key) + { + var db = _conn.GetDatabase(); + var val = await db.StringGetAsync(key.Key); + if (val == default) + return new None(); + + if (IsSupportedType(typeof(T))) + return (T)((IConvertible)val).ToType(typeof(T), null); + + return JsonSerializer.Deserialize(val.ToString(), _opts)!; + } + + public async ValueTask RemoveAsync(TypedKey key) + { + var db = _conn.GetDatabase(); + + return await db.KeyDeleteAsync(key.Key); + } + + public async ValueTask GetOrAddAsync(TypedKey key, Func> createFactory, TimeSpan? expiry = null) + { + var result = await GetAsync(key); + + return await result.Match>( + v => Task.FromResult(v), + async _ => + { + var factoryValue = await createFactory(); + + if (factoryValue is null) + return default; + + await AddAsync(key, factoryValue, expiry); + + // get again to make sure it's the cached value + // and not the late factory value, in case there's a race condition + + var newResult = await GetAsync(key); + + // it's fine to do this, it should blow up if something went wrong. + return newResult.Match( + v => v, + _ => default); + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Common/Configs/BotConfig.cs b/src/EllieBot/Common/Configs/BotConfig.cs new file mode 100644 index 0000000..80186a9 --- /dev/null +++ b/src/EllieBot/Common/Configs/BotConfig.cs @@ -0,0 +1,185 @@ +#nullable disable +using Cloneable; +using EllieBot.Common.Yml; +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Configs; + +[Cloneable] +public sealed partial class BotConfig : ICloneable +{ + [Comment(@"DO NOT CHANGE")] + public int Version { get; set; } = 5; + + [Comment(@"Most commands, when executed, have a small colored line +next to the response. The color depends whether the command +is completed, errored or in progress (pending) +Color settings below are for the color of those lines. +To get color's hex, you can go here https://htmlcolorcodes.com/ +and copy the hex code fo your selected color (marked as #)")] + public ColorConfig Color { get; set; } + + [Comment("Default bot language. It has to be in the list of supported languages (.langli)")] + public CultureInfo DefaultLocale { get; set; } + + [Comment(@"Style in which executed commands will show up in the console. +Allowed values: Simple, Normal, None")] + public ConsoleOutputType ConsoleOutputType { get; set; } + + [Comment(@"Whether the bot will check for new releases every hour")] + public bool CheckForUpdates { get; set; } = true; + + [Comment(@"Do you want any messages sent by users in Bot's DM to be forwarded to the owner(s)?")] + public bool ForwardMessages { get; set; } + + [Comment( + @"Do you want the message to be forwarded only to the first owner specified in the list of owners (in creds.yml), +or all owners? (this might cause the bot to lag if there's a lot of owners specified)")] + public bool ForwardToAllOwners { get; set; } + + [Comment(@"Any messages sent by users in Bot's DM to be forwarded to the specified channel. +This option will only work when ForwardToAllOwners is set to false")] + public ulong? ForwardToChannel { get; set; } + + [Comment(@"When a user DMs the bot with a message which is not a command +they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot. +Supports embeds. How it looks: https://puu.sh/B0BLV.png")] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string DmHelpText { get; set; } + + [Comment(@"Only users who send a DM to the bot containing one of the specified words will get a DmHelpText response. +Case insensitive. +Leave empty to reply with DmHelpText to every DM.")] + public List DmHelpTextKeywords { get; set; } + + [Comment(@"This is the response for the .h command")] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string HelpText { get; set; } + + [Comment(@"List of modules and commands completely blocked on the bot")] + public BlockedConfig Blocked { get; set; } + + [Comment(@"Which string will be used to recognize the commands")] + public string Prefix { get; set; } + + [Comment(@"Toggles whether your bot will group greet/bye messages into a single message every 5 seconds. +1st user who joins will get greeted immediately +If more users join within the next 5 seconds, they will be greeted in groups of 5. +This will cause %user.mention% and other placeholders to be replaced with multiple users. +Keep in mind this might break some of your embeds - for example if you have %user.avatar% in the thumbnail, +it will become invalid, as it will resolve to a list of avatars of grouped users. +note: This setting is primarily used if you're afraid of raids, or you're running medium/large bots where some + servers might get hundreds of people join at once. This is used to prevent the bot from getting ratelimited, + and (slightly) reduce the greet spam in those servers.")] + public bool GroupGreets { get; set; } + + [Comment(@"Whether the bot will rotate through all specified statuses. +This setting can be changed via .ropl command. +See RotatingStatuses submodule in Administration.")] + public bool RotateStatuses { get; set; } + + public BotConfig() + { + var color = new ColorConfig(); + Color = color; + DefaultLocale = new("en-US"); + ConsoleOutputType = ConsoleOutputType.Normal; + ForwardMessages = false; + ForwardToAllOwners = false; + DmHelpText = @"{""description"": ""Type `%prefix%h` for help.""}"; + HelpText = @"{ + ""title"": ""To invite me to your server, use this link"", + ""description"": ""https://discordapp.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303"", + ""color"": 53380, + ""thumbnail"": ""https://cdn.elliebot.net/Ellie.png"", + ""fields"": [ + { + ""name"": ""Useful help commands"", + ""value"": ""`%bot.prefix%modules` Lists all bot modules. +`%prefix%h CommandName` Shows some help about a specific command. +`%prefix%commands ModuleName` Lists all commands in a module."", + ""inline"": false + }, + { + ""name"": ""List of all Commands"", + ""value"": ""https://commands.elliebot.net"", + ""inline"": false + }, + { + ""name"": ""Ellie Support Server"", + ""value"": ""https://discord.gg/etQdZxSyEH"", + ""inline"": true + } + ] +}"; + var blocked = new BlockedConfig(); + Blocked = blocked; + Prefix = "."; + RotateStatuses = false; + GroupGreets = false; + DmHelpTextKeywords = new() + { + "help", + "commands", + "cmds", + "module", + "can you do" + }; + } + + // [Comment(@"Whether the prefix will be a suffix, or prefix. + // For example, if your prefix is ! you will run a command called 'cash' by typing either + // '!cash @Someone' if your prefixIsSuffix: false or + // 'cash @Someone!' if your prefixIsSuffix: true")] + // public bool PrefixIsSuffix { get; set; } + + // public string Prefixed(string text) => PrefixIsSuffix + // ? text + Prefix + // : Prefix + text; + + public string Prefixed(string text) + => Prefix + text; +} + +[Cloneable] +public sealed partial class BlockedConfig +{ + public HashSet Commands { get; set; } + public HashSet Modules { get; set; } + + public BlockedConfig() + { + Modules = new(); + Commands = new(); + } +} + +[Cloneable] +public partial class ColorConfig +{ + [Comment(@"Color used for embed responses when command successfully executes")] + public Rgba32 Ok { get; set; } + + [Comment(@"Color used for embed responses when command has an error")] + public Rgba32 Error { get; set; } + + [Comment(@"Color used for embed responses while command is doing work or is in progress")] + public Rgba32 Pending { get; set; } + + public ColorConfig() + { + Ok = Rgba32.ParseHex("00e584"); + Error = Rgba32.ParseHex("ee281f"); + Pending = Rgba32.ParseHex("faa61a"); + } +} + +public enum ConsoleOutputType +{ + Normal = 0, + Simple = 1, + None = 2 +} \ No newline at end of file diff --git a/src/EllieBot/Common/Configs/IConfigSeria.cs b/src/EllieBot/Common/Configs/IConfigSeria.cs new file mode 100644 index 0000000..cced324 --- /dev/null +++ b/src/EllieBot/Common/Configs/IConfigSeria.cs @@ -0,0 +1,18 @@ +namespace EllieBot.Common.Configs; + +/// +/// Base interface for available config serializers +/// +public interface IConfigSeria +{ + /// + /// Serialize the object to string + /// + public string Serialize(T obj) + where T : notnull; + + /// + /// Deserialize string data into an object of the specified type + /// + public T Deserialize(string data); +} diff --git a/src/EllieBot/Common/ModuleBehaviors/IExecNoCommand.cs b/src/EllieBot/Common/ModuleBehaviors/IExecNoCommand.cs new file mode 100644 index 0000000..a73a7a7 --- /dev/null +++ b/src/EllieBot/Common/ModuleBehaviors/IExecNoCommand.cs @@ -0,0 +1,19 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Executed if no command was found for this message +/// +public interface IExecNoCommand +{ + /// + /// Executed at the end of the lifecycle if no command was found + /// → + /// → + /// → + /// [ | **] + /// + /// + /// + /// A task representing completion + Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg); +} \ No newline at end of file diff --git a/src/EllieBot/Common/ModuleBehaviors/IExecOnMessage.cs b/src/EllieBot/Common/ModuleBehaviors/IExecOnMessage.cs new file mode 100644 index 0000000..eade8d5 --- /dev/null +++ b/src/EllieBot/Common/ModuleBehaviors/IExecOnMessage.cs @@ -0,0 +1,21 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Implemented by modules to handle non-bot messages received +/// +public interface IExecOnMessage +{ + int Priority { get; } + + /// + /// Ran after a non-bot message was received + /// ** → + /// → + /// → + /// [ | ] + /// + /// Guild where the message was sent + /// The message that was received + /// Whether further processing of this message should be blocked + Task ExecOnMessageAsync(IGuild guild, IUserMessage msg); +} diff --git a/src/EllieBot/Common/ModuleBehaviors/IExecPostCommand.cs b/src/EllieBot/Common/ModuleBehaviors/IExecPostCommand.cs new file mode 100644 index 0000000..4f79c31 --- /dev/null +++ b/src/EllieBot/Common/ModuleBehaviors/IExecPostCommand.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after the command successfully finished execution. +/// ***There is no support for this method in EllieBot services.*** +/// It is only meant to be used in marmalade system +/// +public interface IExecPostCommand +{ + /// + /// Executed after a command was successfully executed + /// → + /// → + /// → + /// [** | ] + /// + /// Command context + /// Module name + /// Command name + /// A task representing completion + ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName); +} diff --git a/src/EllieBot/Common/ModuleBehaviors/IExecPreCommand.cs b/src/EllieBot/Common/ModuleBehaviors/IExecPreCommand.cs new file mode 100644 index 0000000..dc14cdd --- /dev/null +++ b/src/EllieBot/Common/ModuleBehaviors/IExecPreCommand.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after a command was found but before it was executed. +/// Able to block further processing of a command +/// +public interface IExecPreCommand +{ + public int Priority { get; } + + /// + /// + /// Ran after a command was found but before execution. + /// + /// → + /// → + /// ** → + /// [ | ] + /// + /// Command context + /// Name of the module + /// Command info + /// Whether further processing of the command is blocked + Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command); +} diff --git a/src/EllieBot/Common/ModuleBehaviors/IInputTransformer.cs b/src/EllieBot/Common/ModuleBehaviors/IInputTransformer.cs new file mode 100644 index 0000000..90ae000 --- /dev/null +++ b/src/EllieBot/Common/ModuleBehaviors/IInputTransformer.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Implemented by services which may transform input before a command is searched for +/// +public interface IInputTransformer +{ + /// + /// Ran after a non-bot message was received + /// -> + /// ** -> + /// -> + /// [ OR ] + /// + /// Guild + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// New input, if any, otherwise null + Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input); +} diff --git a/src/EllieBot/Common/ModuleBehaviors/IReadyExecutor.cs b/src/EllieBot/Common/ModuleBehaviors/IReadyExecutor.cs new file mode 100644 index 0000000..9ae8211 --- /dev/null +++ b/src/EllieBot/Common/ModuleBehaviors/IReadyExecutor.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// All services which need to execute something after +/// the bot is ready should implement this interface +/// +public interface IReadyExecutor +{ + /// + /// Executed when bot is ready + /// + public Task OnReadyAsync(); +} diff --git a/src/EllieBot/Common/Yml/CommentAttribute.cs b/src/EllieBot/Common/Yml/CommentAttribute.cs new file mode 100644 index 0000000..9d3d7ec --- /dev/null +++ b/src/EllieBot/Common/Yml/CommentAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Common.Yml; + +[AttributeUsage(AttributeTargets.Property)] +public class CommentAttribute : Attribute +{ + public string Comment { get; } + + public CommentAttribute(string comment) + => Comment = comment; +} diff --git a/src/EllieBot/Common/Yml/CommentGatheringTypeInspector.cs b/src/EllieBot/Common/Yml/CommentGatheringTypeInspector.cs new file mode 100644 index 0000000..1a81978 --- /dev/null +++ b/src/EllieBot/Common/Yml/CommentGatheringTypeInspector.cs @@ -0,0 +1,65 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.TypeInspectors; + +namespace EllieBot.Common.Yml; + +public class CommentGatheringTypeInspector : TypeInspectorSkeleton +{ + private readonly ITypeInspector _innerTypeDescriptor; + + public CommentGatheringTypeInspector(ITypeInspector innerTypeDescriptor) + => _innerTypeDescriptor = innerTypeDescriptor ?? throw new ArgumentNullException(nameof(innerTypeDescriptor)); + + public override IEnumerable GetProperties(Type type, object container) + => _innerTypeDescriptor.GetProperties(type, container).Select(d => new CommentsPropertyDescriptor(d)); + + private sealed class CommentsPropertyDescriptor : IPropertyDescriptor + { + public string Name { get; } + + public Type Type + => _baseDescriptor.Type; + + public Type TypeOverride + { + get => _baseDescriptor.TypeOverride; + set => _baseDescriptor.TypeOverride = value; + } + + public int Order { get; set; } + + public ScalarStyle ScalarStyle + { + get => _baseDescriptor.ScalarStyle; + set => _baseDescriptor.ScalarStyle = value; + } + + public bool CanWrite + => _baseDescriptor.CanWrite; + + private readonly IPropertyDescriptor _baseDescriptor; + + public CommentsPropertyDescriptor(IPropertyDescriptor baseDescriptor) + { + _baseDescriptor = baseDescriptor; + Name = baseDescriptor.Name; + } + + public void Write(object target, object value) + => _baseDescriptor.Write(target, value); + + public T GetCustomAttribute() + where T : Attribute + => _baseDescriptor.GetCustomAttribute(); + + public IObjectDescriptor Read(object target) + { + var comment = _baseDescriptor.GetCustomAttribute(); + return comment is not null + ? new CommentsObjectDescriptor(_baseDescriptor.Read(target), comment.Comment) + : _baseDescriptor.Read(target); + } + } +} diff --git a/src/EllieBot/Common/Yml/CommentsObjectDescriptor.cs b/src/EllieBot/Common/Yml/CommentsObjectDescriptor.cs new file mode 100644 index 0000000..9dc61f9 --- /dev/null +++ b/src/EllieBot/Common/Yml/CommentsObjectDescriptor.cs @@ -0,0 +1,30 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public sealed class CommentsObjectDescriptor : IObjectDescriptor +{ + public string Comment { get; } + + public object Value + => _innerDescriptor.Value; + + public Type Type + => _innerDescriptor.Type; + + public Type StaticType + => _innerDescriptor.StaticType; + + public ScalarStyle ScalarStyle + => _innerDescriptor.ScalarStyle; + + private readonly IObjectDescriptor _innerDescriptor; + + public CommentsObjectDescriptor(IObjectDescriptor innerDescriptor, string comment) + { + _innerDescriptor = innerDescriptor; + Comment = comment; + } +} diff --git a/src/EllieBot/Common/Yml/CommentsObjectGraphVisitor.cs b/src/EllieBot/Common/Yml/CommentsObjectGraphVisitor.cs new file mode 100644 index 0000000..1c89a95 --- /dev/null +++ b/src/EllieBot/Common/Yml/CommentsObjectGraphVisitor.cs @@ -0,0 +1,29 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.ObjectGraphVisitors; + +namespace EllieBot.Common.Yml; + +public class CommentsObjectGraphVisitor : ChainedObjectGraphVisitor +{ + public CommentsObjectGraphVisitor(IObjectGraphVisitor nextVisitor) + : base(nextVisitor) + { + } + + public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context) + { + if (value is CommentsObjectDescriptor commentsDescriptor + && !string.IsNullOrWhiteSpace(commentsDescriptor.Comment)) + { + var parts = commentsDescriptor.Comment.Split('\n'); + + foreach (var part in parts) + context.Emit(new Comment(part.Trim(), false)); + } + + return base.EnterMapping(key, value, context); + } +} \ No newline at end of file diff --git a/src/EllieBot/Common/Yml/MultilineScalarFlowStyleEmitter.cs b/src/EllieBot/Common/Yml/MultilineScalarFlowStyleEmitter.cs new file mode 100644 index 0000000..cf9e15f --- /dev/null +++ b/src/EllieBot/Common/Yml/MultilineScalarFlowStyleEmitter.cs @@ -0,0 +1,35 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.EventEmitters; + +namespace EllieBot.Common.Yml; + +public class MultilineScalarFlowStyleEmitter : ChainedEventEmitter +{ + public MultilineScalarFlowStyleEmitter(IEventEmitter nextEmitter) + : base(nextEmitter) + { + } + + public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter) + { + if (typeof(string).IsAssignableFrom(eventInfo.Source.Type)) + { + var value = eventInfo.Source.Value as string; + if (!string.IsNullOrEmpty(value)) + { + var isMultiLine = value.IndexOfAny(new[] { '\r', '\n', '\x85', '\x2028', '\x2029' }) >= 0; + if (isMultiLine) + { + eventInfo = new(eventInfo.Source) + { + Style = ScalarStyle.Literal + }; + } + } + } + + nextEmitter.Emit(eventInfo, emitter); + } +} \ No newline at end of file diff --git a/src/EllieBot/Common/Yml/Rgba32Converter.cs b/src/EllieBot/Common/Yml/Rgba32Converter.cs new file mode 100644 index 0000000..12f6cf9 --- /dev/null +++ b/src/EllieBot/Common/Yml/Rgba32Converter.cs @@ -0,0 +1,47 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public class Rgba32Converter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(Rgba32); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = Rgba32.ParseHex(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var color = (Rgba32)value; + var val = (uint)((color.B << 0) | (color.G << 8) | (color.R << 16)); + emitter.Emit(new Scalar(val.ToString("X6").ToLower())); + } +} + +public class CultureInfoConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(CultureInfo); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = new CultureInfo(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var ci = (CultureInfo)value; + emitter.Emit(new Scalar(ci.Name)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Common/Yml/UriConverter.cs b/src/EllieBot/Common/Yml/UriConverter.cs new file mode 100644 index 0000000..66e2ca0 --- /dev/null +++ b/src/EllieBot/Common/Yml/UriConverter.cs @@ -0,0 +1,25 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public class UriConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(Uri); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = new Uri(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var uri = (Uri)value; + emitter.Emit(new Scalar(uri.ToString())); + } +} \ No newline at end of file diff --git a/src/EllieBot/Common/Yml/Yaml.cs b/src/EllieBot/Common/Yml/Yaml.cs new file mode 100644 index 0000000..2c65698 --- /dev/null +++ b/src/EllieBot/Common/Yml/Yaml.cs @@ -0,0 +1,28 @@ +#nullable disable +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace EllieBot.Common.Yml; + +public class Yaml +{ + public static ISerializer Serializer + => new SerializerBuilder().WithTypeInspector(inner => new CommentGatheringTypeInspector(inner)) + .WithEmissionPhaseObjectGraphVisitor(args + => new CommentsObjectGraphVisitor(args.InnerVisitor)) + .WithEventEmitter(args => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .WithTypeConverter(new Rgba32Converter()) + .WithTypeConverter(new CultureInfoConverter()) + .WithTypeConverter(new UriConverter()) + .Build(); + + public static IDeserializer Deserializer + => new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new Rgba32Converter()) + .WithTypeConverter(new CultureInfoConverter()) + .WithTypeConverter(new UriConverter()) + .IgnoreUnmatchedProperties() + .Build(); +} \ No newline at end of file diff --git a/src/EllieBot/Common/Yml/YamlHelper.cs b/src/EllieBot/Common/Yml/YamlHelper.cs new file mode 100644 index 0000000..25dbfd6 --- /dev/null +++ b/src/EllieBot/Common/Yml/YamlHelper.cs @@ -0,0 +1,48 @@ +#nullable disable +namespace EllieBot.Common.Yml; + +public class YamlHelper +{ + // https://github.com/aaubry/YamlDotNet/blob/0f4cc205e8b2dd8ef6589d96de32bf608a687c6f/YamlDotNet/Core/Scanner.cs#L1687 + /// + /// This is modified code from yamldotnet's repo which handles parsing unicode code points + /// it is needed as yamldotnet doesn't support unescaped unicode characters + /// + /// Unicode code point + /// Actual character + public static string UnescapeUnicodeCodePoint(string point) + { + var character = 0; + + // Scan the character value. + + foreach (var c in point) + { + if (!IsHex(c)) + return point; + + character = (character << 4) + AsHex(c); + } + + // Check the value and write the character. + + if (character is (>= 0xD800 and <= 0xDFFF) or > 0x10FFFF) + return point; + + return char.ConvertFromUtf32(character); + } + + public static bool IsHex(char c) + => c is (>= '0' and <= '9') or (>= 'A' and <= 'F') or (>= 'a' and <= 'f'); + + public static int AsHex(char c) + { + if (c <= '9') + return c - '0'; + + if (c <= 'F') + return c - 'A' + 10; + + return c - 'a' + 10; + } +} \ No newline at end of file