From a645caf495ad3dcb0961742c1ce1785aad304ee9 Mon Sep 17 00:00:00 2001 From: Toastie Date: Sat, 23 Mar 2024 18:42:46 +1300 Subject: [PATCH] Updated Ellie.Marmalade --- EllieBot.sln | 13 +- .../Attributes/FilterAttribute.cs | 10 ++ .../Attributes/MarmaladePermAttribute.cs | 10 ++ .../Attributes/bot_owner_onlyAttribute.cs | 7 + .../Attributes/bot_permAttribute.cs | 22 +++ .../Attributes/cmdAttribute.cs | 37 +++++ .../Attributes/injectAttribute.cs | 10 ++ .../Attributes/leftoverAttribute.cs | 10 ++ .../Attributes/prioAttribute.cs | 20 +++ .../Attributes/svcAttribute.cs | 23 +++ .../Attributes/user_permAttribute.cs | 22 +++ src/Ellie.Marmalade/Canary.cs | 143 ++++++++++++++++++ src/Ellie.Marmalade/Context/AnyContext.cs | 52 +++++++ src/Ellie.Marmalade/Context/DmContext.cs | 11 ++ src/Ellie.Marmalade/Context/GuildContext.cs | 12 ++ src/Ellie.Marmalade/Ellie.Marmalade.csproj | 24 +++ src/Ellie.Marmalade/EmbedColor.cs | 8 + .../Extensions/EmbedBuilderExtensions.cs | 14 ++ .../Extensions/MarmaladeExtensions.cs | 66 ++++++++ src/Ellie.Marmalade/IEmbedBuilder.cs | 18 +++ .../ParamParser/ParamParser.cs | 16 ++ .../ParamParser/ParseResult.cs | 48 ++++++ src/Ellie.Marmalade/README.md | 1 + src/Ellie.Marmalade/Strings/CommandStrings.cs | 24 +++ .../Strings/IMarmaladeStrings.cs | 15 ++ .../Strings/IMarmaladeStringsProvider.cs | 28 ++++ .../Strings/LocalMarmaladeStringsProvider.cs | 40 +++++ .../Strings/MarmaladeStrings.cs | 79 ++++++++++ src/Ellie.Marmalade/Strings/StringsLoader.cs | 137 +++++++++++++++++ 29 files changed, 917 insertions(+), 3 deletions(-) create mode 100644 src/Ellie.Marmalade/Attributes/FilterAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/bot_permAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/cmdAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/injectAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/leftoverAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/prioAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/svcAttribute.cs create mode 100644 src/Ellie.Marmalade/Attributes/user_permAttribute.cs create mode 100644 src/Ellie.Marmalade/Canary.cs create mode 100644 src/Ellie.Marmalade/Context/AnyContext.cs create mode 100644 src/Ellie.Marmalade/Context/DmContext.cs create mode 100644 src/Ellie.Marmalade/Context/GuildContext.cs create mode 100644 src/Ellie.Marmalade/Ellie.Marmalade.csproj create mode 100644 src/Ellie.Marmalade/EmbedColor.cs create mode 100644 src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs create mode 100644 src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs create mode 100644 src/Ellie.Marmalade/IEmbedBuilder.cs create mode 100644 src/Ellie.Marmalade/ParamParser/ParamParser.cs create mode 100644 src/Ellie.Marmalade/ParamParser/ParseResult.cs create mode 100644 src/Ellie.Marmalade/README.md create mode 100644 src/Ellie.Marmalade/Strings/CommandStrings.cs create mode 100644 src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs create mode 100644 src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs create mode 100644 src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs create mode 100644 src/Ellie.Marmalade/Strings/MarmaladeStrings.cs create mode 100644 src/Ellie.Marmalade/Strings/StringsLoader.cs diff --git a/EllieBot.sln b/EllieBot.sln index b112a25..bd0e604 100644 --- a/EllieBot.sln +++ b/EllieBot.sln @@ -21,11 +21,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ayu.Discord.Voice", "src\ay EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Tests", "src\EllieBot.Tests\EllieBot.Tests.csproj", "{179DF3B3-AD32-4335-8231-9818338DF3A2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Coordinator", "src\EllieBot.Coordinator\EllieBot.Coordinator.csproj", "{A631DDF0-3AD1-4CB9-8458-314B1320868A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Coordinator", "src\EllieBot.Coordinator\EllieBot.Coordinator.csproj", "{A631DDF0-3AD1-4CB9-8458-314B1320868A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Generators", "src\EllieBot.Generators\EllieBot.Generators.csproj", "{CB1A5307-DD85-4795-8A8A-A25D36DADC51}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Generators", "src\EllieBot.Generators\EllieBot.Generators.csproj", "{CB1A5307-DD85-4795-8A8A-A25D36DADC51}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.VotesApi", "src\EllieBot.VotesApi\EllieBot.VotesApi.csproj", "{F1A77F56-71B0-430E-AE46-94CDD7D43874}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.VotesApi", "src\EllieBot.VotesApi\EllieBot.VotesApi.csproj", "{F1A77F56-71B0-430E-AE46-94CDD7D43874}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Marmalade", "src\Ellie.Marmalade\Ellie.Marmalade.csproj", "{76AC715D-12FF-4CBE-9585-A861139A2D0C}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -57,6 +59,10 @@ Global {F1A77F56-71B0-430E-AE46-94CDD7D43874}.Debug|Any CPU.Build.0 = Debug|Any CPU {F1A77F56-71B0-430E-AE46-94CDD7D43874}.Release|Any CPU.ActiveCfg = Release|Any CPU {F1A77F56-71B0-430E-AE46-94CDD7D43874}.Release|Any CPU.Build.0 = Release|Any CPU + {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -69,6 +75,7 @@ Global {A631DDF0-3AD1-4CB9-8458-314B1320868A} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} {CB1A5307-DD85-4795-8A8A-A25D36DADC51} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} {F1A77F56-71B0-430E-AE46-94CDD7D43874} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {76AC715D-12FF-4CBE-9585-A861139A2D0C} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4} diff --git a/src/Ellie.Marmalade/Attributes/FilterAttribute.cs b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs new file mode 100644 index 0000000..1f2858e --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs @@ -0,0 +1,10 @@ +namespace Ellie.Canary; + +/// +/// Overridden to implement custom checks which commands have to pass in order to be executed. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] +public abstract class FilterAttribute : Attribute +{ + public abstract ValueTask CheckAsync(AnyContext ctx); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs b/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs new file mode 100644 index 0000000..0e7003c --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs @@ -0,0 +1,10 @@ +namespace Ellie.Canary; + +/// +/// Used as a marker class for bot_perm and user_perm Attributes +/// Has no functionality. +/// +public abstract class MarmaladePermAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs b/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs new file mode 100644 index 0000000..cbffa6c --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs @@ -0,0 +1,7 @@ +namespace Ellie.Canary; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class bot_owner_onlyAttribute : MarmaladePermAttribute +{ + +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs new file mode 100644 index 0000000..faaede2 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs @@ -0,0 +1,22 @@ +using Discord; + +namespace Ellie.Canary; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class bot_permAttribute : MarmaladePermAttribute +{ + public GuildPermission? GuildPerm { get; } + public ChannelPermission? ChannelPerm { get; } + + public bot_permAttribute(GuildPermission perm) + { + GuildPerm = perm; + ChannelPerm = null; + } + + public bot_permAttribute(ChannelPermission perm) + { + ChannelPerm = perm; + GuildPerm = null; + } +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/cmdAttribute.cs b/src/Ellie.Marmalade/Attributes/cmdAttribute.cs new file mode 100644 index 0000000..f40ca2e --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/cmdAttribute.cs @@ -0,0 +1,37 @@ +namespace Ellie.Canary; + +/// +/// Marks a method as a snek command +/// +[AttributeUsage(AttributeTargets.Method)] +public class cmdAttribute : Attribute +{ + /// + /// Command description. Avoid using, as cmds.yml is preferred + /// + public string? desc { get; set; } + + /// + /// Command args examples. Avoid using, as cmds.yml is preferred + /// + public string[]? args { get; set; } + + /// + /// Command aliases + /// + public string[] Aliases { get; } + + public cmdAttribute() + { + desc = null; + args = null; + Aliases = Array.Empty(); + } + + public cmdAttribute(params string[] aliases) + { + Aliases = aliases; + desc = null; + args = null; + } +} diff --git a/src/Ellie.Marmalade/Attributes/injectAttribute.cs b/src/Ellie.Marmalade/Attributes/injectAttribute.cs new file mode 100644 index 0000000..3380b9d --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/injectAttribute.cs @@ -0,0 +1,10 @@ +namespace Ellie.Canary; + +/// +/// Marks services in command arguments for injection. +/// The injected services must come after the context and before any input parameters. +/// +public class injectAttribute : Attribute +{ + +} diff --git a/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs b/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs new file mode 100644 index 0000000..d632715 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs @@ -0,0 +1,10 @@ +namespace Ellie.Canary; + +/// +/// Marks the parameter to take +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class leftoverAttribute : Attribute +{ + +} diff --git a/src/Ellie.Marmalade/Attributes/prioAttribute.cs b/src/Ellie.Marmalade/Attributes/prioAttribute.cs new file mode 100644 index 0000000..cf6d0d4 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/prioAttribute.cs @@ -0,0 +1,20 @@ +namespace Ellie.Canary; + +/// +/// Sets the priority of a command in case there are multiple commands with the same name but different parameters. +/// Higher value means higher priority. +/// +[AttributeUsage(AttributeTargets.Method)] +public class prioAttribute : Attribute +{ + public int Priority { get; } + + /// + /// Snek command priority + /// + /// Priority value. The higher the value, the higher the priority + public prioAttribute(int priority) + { + Priority = priority; + } +} diff --git a/src/Ellie.Marmalade/Attributes/svcAttribute.cs b/src/Ellie.Marmalade/Attributes/svcAttribute.cs new file mode 100644 index 0000000..eb225b0 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/svcAttribute.cs @@ -0,0 +1,23 @@ +namespace Ellie.Canary; + +/// +/// Marks the class as a service which can be used within the same Medusa +/// +[AttributeUsage(AttributeTargets.Class)] +public class svcAttribute : Attribute +{ + public Lifetime Lifetime { get; } + public svcAttribute(Lifetime lifetime) + { + Lifetime = lifetime; + } +} + +/// +/// Lifetime for +/// +public enum Lifetime +{ + Singleton, + Transient +} diff --git a/src/Ellie.Marmalade/Attributes/user_permAttribute.cs b/src/Ellie.Marmalade/Attributes/user_permAttribute.cs new file mode 100644 index 0000000..a4826db --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/user_permAttribute.cs @@ -0,0 +1,22 @@ +using Discord; + +namespace Ellie.Canary; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class user_permAttribute : MarmaladePermAttribute +{ + public GuildPermission? GuildPerm { get; } + public ChannelPermission? ChannelPerm { get; } + + public user_permAttribute(GuildPermission perm) + { + GuildPerm = perm; + ChannelPerm = null; + } + + public user_permAttribute(ChannelPermission perm) + { + ChannelPerm = perm; + GuildPerm = null; + } +} diff --git a/src/Ellie.Marmalade/Canary.cs b/src/Ellie.Marmalade/Canary.cs new file mode 100644 index 0000000..5c50aa8 --- /dev/null +++ b/src/Ellie.Marmalade/Canary.cs @@ -0,0 +1,143 @@ +using Discord; + +namespace Ellie.Canary; + +/// +/// The base class which will be loaded as a module into NadekoBot +/// Any user-defined snek has to inherit from this class. +/// Sneks get instantiated ONLY ONCE during the loading, +/// and any snek commands will be executed on the same instance. +/// +public abstract class Snek : IAsyncDisposable +{ + /// + /// Name of the snek. Defaults to the lowercase class name + /// + public virtual string Name + => GetType().Name.ToLowerInvariant(); + + /// + /// The prefix required before the command name. For example + /// if you set this to 'test' then a command called 'cmd' will have to be invoked by using + /// '.test cmd' instead of `.cmd` + /// + public virtual string Prefix + => string.Empty; + + /// + /// Executed once this snek has been instantiated and before any command is executed. + /// + /// A representing completion + public virtual ValueTask InitializeAsync() + => default; + + /// + /// Override to cleanup any resources or references which might hold this snek in memory + /// + /// + public virtual ValueTask DisposeAsync() + => default; + + /// + /// This method is called right after the message was received by the bot. + /// You can use this method to make the bot conditionally ignore some messages and prevent further processing. + /// Execution order: + /// + /// ** → + /// → + /// → + /// OR + /// + /// + /// Guild in which the message was sent + /// Message received by the bot + /// A representing whether the message should be ignored and not processed further + public virtual ValueTask ExecOnMessageAsync(IGuild? guild, IUserMessage msg) + => default; + + /// + /// Override this method to modify input before the bot searches for any commands matching the input + /// Executed after + /// This is useful if you want to reinterpret the message under some conditions + /// Execution order: + /// + /// → + /// ** → + /// → + /// OR + /// + /// + /// Guild in which the message was sent + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// A representing new, potentially modified content + public virtual ValueTask ExecInputTransformAsync( + IGuild? guild, + IMessageChannel channel, + IUser user, + string input + ) + => default; + + /// + /// This method is called after the command was found but not executed, + /// and can be used to prevent the command's execution. + /// The command information doesn't have to be from this snek as this method + /// will be called when *any* command from any module or snek was found. + /// You can choose to prevent the execution of the command by returning "true" value. + /// Execution order: + /// + /// → + /// → + /// ** → + /// OR + /// + /// + /// Command context + /// Name of the snek or module from which the command originates + /// Name of the command which is about to be executed + /// A representing whether the execution should be blocked + public virtual ValueTask ExecPreCommandAsync( + AnyContext context, + string moduleName, + string commandName + ) + => default; + + /// + /// This method is called after the command was succesfully executed. + /// If this method was called, then will not be executed + /// Execution order: + /// + /// → + /// → + /// → + /// ** OR + /// + /// + /// A representing completion + public virtual ValueTask ExecPostCommandAsync(AnyContext ctx, string moduleName, string commandName) + => default; + + /// + /// This method is called if no command was found for the input. + /// Useful if you want to have games or features which take arbitrary input + /// but ignore any messages which were blocked or caused a command execution + /// If this method was called, then will not be executed + /// Execution order: + /// + /// → + /// → + /// → + /// OR ** + /// + /// + /// A representing completion + public virtual ValueTask ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + => default; +} + +public readonly struct ExecResponse +{ +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Context/AnyContext.cs b/src/Ellie.Marmalade/Context/AnyContext.cs new file mode 100644 index 0000000..f333f1e --- /dev/null +++ b/src/Ellie.Marmalade/Context/AnyContext.cs @@ -0,0 +1,52 @@ +using Discord; +using EllieBot; + +namespace Ellie.Canary; + +/// +/// Commands which take this class as a first parameter can be executed in both DMs and Servers +/// +public abstract class AnyContext +{ + /// + /// Channel from the which the command is invoked + /// + public abstract IMessageChannel Channel { get; } + + /// + /// Message which triggered the command + /// + public abstract IUserMessage Message { get; } + + /// + /// The user who invoked the command + /// + public abstract IUser User { get; } + + /// + /// Bot user + /// + public abstract ISelfUser Bot { get; } + + /// + /// Provides access to strings used by this medusa + /// + public abstract IMarmaladeStrings Strings { get; } + + /// + /// Gets a formatted localized string using a key and arguments which should be formatted in + /// + /// The key of the string as specified in localization files + /// Arguments (if any) to format in + /// A formatted localized string + public abstract string GetText(string key, object[]? args = null); + + /// + /// Creates a context-aware instance + /// (future feature for guild-based embed colors) + /// Any code dealing with embeds should use it for future-proofness + /// instead of manually creating embedbuilder instances + /// + /// A context-aware instance + public abstract IEmbedBuilder Embed(); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Context/DmContext.cs b/src/Ellie.Marmalade/Context/DmContext.cs new file mode 100644 index 0000000..3810984 --- /dev/null +++ b/src/Ellie.Marmalade/Context/DmContext.cs @@ -0,0 +1,11 @@ +using Discord; + +namespace Ellie.Canary; + +/// +/// Commands which take this type as the first parameter can only be executed in DMs +/// +public abstract class DmContext : AnyContext +{ + public abstract override IDMChannel Channel { get; } +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Context/GuildContext.cs b/src/Ellie.Marmalade/Context/GuildContext.cs new file mode 100644 index 0000000..916f945 --- /dev/null +++ b/src/Ellie.Marmalade/Context/GuildContext.cs @@ -0,0 +1,12 @@ +using Discord; + +namespace Ellie.Canary; + +/// +/// Commands which take this type as a first parameter can only be executed in a server +/// +public abstract class GuildContext : AnyContext +{ + public abstract override ITextChannel Channel { get; } + public abstract IGuild Guild { get; } +} diff --git a/src/Ellie.Marmalade/Ellie.Marmalade.csproj b/src/Ellie.Marmalade/Ellie.Marmalade.csproj new file mode 100644 index 0000000..d296490 --- /dev/null +++ b/src/Ellie.Marmalade/Ellie.Marmalade.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + enable + preview + true + Ellie.Canary + + The EllieBot Devs + + + + + + + + + + 5.0.0 + + + diff --git a/src/Ellie.Marmalade/EmbedColor.cs b/src/Ellie.Marmalade/EmbedColor.cs new file mode 100644 index 0000000..cd492b5 --- /dev/null +++ b/src/Ellie.Marmalade/EmbedColor.cs @@ -0,0 +1,8 @@ +namespace EllieBot; + +public enum EmbedColor +{ + Ok, + Pending, + Error +} diff --git a/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs b/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs new file mode 100644 index 0000000..6b6d50d --- /dev/null +++ b/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs @@ -0,0 +1,14 @@ +namespace EllieBot; + +public static class EmbedBuilderExtensions +{ + public static IEmbedBuilder WithOkColor(this IEmbedBuilder eb) + => eb.WithColor(EmbedColor.Ok); + + public static IEmbedBuilder WithPendingColor(this IEmbedBuilder eb) + => eb.WithColor(EmbedColor.Pending); + + public static IEmbedBuilder WithErrorColor(this IEmbedBuilder eb) + => eb.WithColor(EmbedColor.Error); + +} diff --git a/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs new file mode 100644 index 0000000..c72859f --- /dev/null +++ b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs @@ -0,0 +1,66 @@ +using Discord; +using Ellie.Canary; + +namespace EllieBot; + +public static class MarmaladeExtensions +{ + public static Task EmbedAsync(this IMessageChannel ch, IEmbedBuilder embed, string msg = "") + => ch.SendMessageAsync(msg, + embed: embed.Build(), + options: new() + { + RetryMode = RetryMode.Retry502 + }); + + // unlocalized + public static Task SendConfirmAsync(this IMessageChannel ch, AnyContext ctx, string msg) + => ch.EmbedAsync(ctx.Embed().WithOkColor().WithDescription(msg)); + + public static Task SendPendingAsync(this IMessageChannel ch, AnyContext ctx, string msg) + => ch.EmbedAsync(ctx.Embed().WithPendingColor().WithDescription(msg)); + + public static Task SendErrorAsync(this IMessageChannel ch, AnyContext ctx, string msg) + => ch.EmbedAsync(ctx.Embed().WithErrorColor().WithDescription(msg)); + + // unlocalized + public static Task SendConfirmAsync(this AnyContext ctx, string msg) + => ctx.Channel.SendConfirmAsync(ctx, msg); + + public static Task SendPendingAsync(this AnyContext ctx, string msg) + => ctx.Channel.SendPendingAsync(ctx, msg); + + public static Task SendErrorAsync(this AnyContext ctx, string msg) + => ctx.Channel.SendErrorAsync(ctx, msg); + + // localized + public static Task ConfirmAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("✅")); + + public static Task ErrorAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("❌")); + + public static Task WarningAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("⚠️")); + + public static Task WaitAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("🤔")); + + public static Task ErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendErrorAsync(ctx.GetText(key, args)); + + public static Task PendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendPendingAsync(ctx.GetText(key, args)); + + public static Task ConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendConfirmAsync(ctx.GetText(key, args)); + + public static Task ReplyErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}"); + + public static Task ReplyPendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}"); + + public static Task ReplyConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}"); +} diff --git a/src/Ellie.Marmalade/IEmbedBuilder.cs b/src/Ellie.Marmalade/IEmbedBuilder.cs new file mode 100644 index 0000000..0d77367 --- /dev/null +++ b/src/Ellie.Marmalade/IEmbedBuilder.cs @@ -0,0 +1,18 @@ +using Discord; + +namespace EllieBot; + +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); + IEmbedBuilder WithDiscordColor(Color color); + Embed Build(); + IEmbedBuilder WithUrl(string url); + IEmbedBuilder WithImageUrl(string url); + IEmbedBuilder WithThumbnailUrl(string url); +} diff --git a/src/Ellie.Marmalade/ParamParser/ParamParser.cs b/src/Ellie.Marmalade/ParamParser/ParamParser.cs new file mode 100644 index 0000000..feec7d1 --- /dev/null +++ b/src/Ellie.Marmalade/ParamParser/ParamParser.cs @@ -0,0 +1,16 @@ +namespace Ellie.Canary; + +/// +/// Overridden to implement parsers for custom types +/// +/// Type into which to parse the input +public abstract class ParamParser +{ + /// + /// Overridden to implement parsing logic + /// + /// Context + /// Input to parse + /// A with successful or failed status + public abstract ValueTask> TryParseAsync(AnyContext ctx, string input); +} diff --git a/src/Ellie.Marmalade/ParamParser/ParseResult.cs b/src/Ellie.Marmalade/ParamParser/ParseResult.cs new file mode 100644 index 0000000..81a8607 --- /dev/null +++ b/src/Ellie.Marmalade/ParamParser/ParseResult.cs @@ -0,0 +1,48 @@ +namespace Ellie.Canary; + +public readonly struct ParseResult +{ + /// + /// Whether the parsing was successful + /// + public bool IsSuccess { get; private init; } + + /// + /// Parsed value. It should only have value if is set to true + /// + public T? Data { get; private init; } + + /// + /// Instantiate a **successful** parse result + /// + /// Parsed value + public ParseResult(T data) + { + Data = data; + IsSuccess = true; + } + + + /// + /// Create a new with IsSuccess = false + /// + /// A new + public static ParseResult Fail() + => new ParseResult + { + IsSuccess = false, + Data = default, + }; + + /// + /// Create a new with IsSuccess = true + /// + /// Value of the parsed object + /// A new + public static ParseResult Success(T obj) + => new ParseResult + { + IsSuccess = true, + Data = obj, + }; +} diff --git a/src/Ellie.Marmalade/README.md b/src/Ellie.Marmalade/README.md new file mode 100644 index 0000000..98e851d --- /dev/null +++ b/src/Ellie.Marmalade/README.md @@ -0,0 +1 @@ +This is the library which is the base of any marmalade. \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/CommandStrings.cs b/src/Ellie.Marmalade/Strings/CommandStrings.cs new file mode 100644 index 0000000..8328fae --- /dev/null +++ b/src/Ellie.Marmalade/Strings/CommandStrings.cs @@ -0,0 +1,24 @@ +using YamlDotNet.Serialization; + +namespace Ellie.Canary; + +public readonly struct CommandStrings +{ + public CommandStrings(string? desc, string[]? args) + { + Desc = desc; + Args = args; + } + + [YamlMember(Alias = "desc")] + public string? Desc { get; init; } + + [YamlMember(Alias = "args")] + public string[]? Args { get; init; } + + public void Deconstruct(out string? desc, out string[]? args) + { + desc = Desc; + args = Args; + } +} diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs new file mode 100644 index 0000000..1527bfe --- /dev/null +++ b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs @@ -0,0 +1,15 @@ +using System.Globalization; + +namespace Ellie.Canary; + +/// +/// Defines methods to retrieve and reload marmalade strings +/// +public interface IMarmaladeStrings +{ + // string GetText(string key, ulong? guildId = null, params object[] data); + string? GetText(string key, CultureInfo locale, params object[] data); + void Reload(); + CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo); + string? GetDescription(CultureInfo? locale); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs new file mode 100644 index 0000000..d2fb1a7 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs @@ -0,0 +1,28 @@ +namespace Ellie.Canary; + +/// +/// Implemented by classes which provide localized strings in their own ways +/// +public interface IMarmaladeStringsProvider +{ + /// + /// Gets localized string + /// + /// Language name + /// String key + /// Localized string + string? GetText(string localeName, string key); + + /// + /// Reloads string cache + /// + void Reload(); + + // /// + // /// Gets command arg examples and description + // /// + // /// Language name + // /// Command name + // CommandStrings GetCommandStrings(string localeName, string commandName); + CommandStrings? GetCommandStrings(string localeName, string commandName); +} diff --git a/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs b/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs new file mode 100644 index 0000000..2bb6ca6 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs @@ -0,0 +1,40 @@ +namespace Ellie.Canary; + +public class LocalMarmaladeStringsProvider : IMarmaladeStringsProvider +{ + private readonly StringsLoader _source; + private IReadOnlyDictionary> _responseStrings; + private IReadOnlyDictionary> _commandStrings; + + public LocalMarmaladeStringsProvider(StringsLoader source) + { + _source = source; + _responseStrings = _source.GetResponseStrings(); + _commandStrings = _source.GetCommandStrings(); + } + + public void Reload() + { + _responseStrings = _source.GetResponseStrings(); + _commandStrings = _source.GetCommandStrings(); + } + + + public string? GetText(string localeName, string key) + { + if (_responseStrings.TryGetValue(localeName.ToLowerInvariant(), out var langStrings) + && langStrings.TryGetValue(key.ToLowerInvariant(), out var text)) + return text; + + return null; + } + + public CommandStrings? GetCommandStrings(string localeName, string commandName) + { + if (_commandStrings.TryGetValue(localeName.ToLowerInvariant(), out var langStrings) + && langStrings.TryGetValue(commandName.ToLowerInvariant(), out var strings)) + return strings; + + return null; + } +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs new file mode 100644 index 0000000..2880764 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs @@ -0,0 +1,79 @@ +using System.Globalization; +using Serilog; + +namespace Ellie.Canary; + +public class MarmaladeStrings : IMarmaladeStrings +{ + /// + /// Used as failsafe in case response key doesn't exist in the selected or default language. + /// + private readonly CultureInfo _usCultureInfo = new("en-US"); + + private readonly IMarmaladeStringsProvider _stringsProvider; + + public MarmaladeStrings(IMarmaladeStringsProvider stringsProvider) + { + _stringsProvider = stringsProvider; + } + + private string? GetString(string key, CultureInfo cultureInfo) + => _stringsProvider.GetText(cultureInfo.Name, key); + + public string? GetText(string key, CultureInfo cultureInfo) + => GetString(key, cultureInfo) + ?? GetString(key, _usCultureInfo); + + public string? GetText(string key, CultureInfo cultureInfo, params object[] data) + { + var text = GetText(key, cultureInfo); + + if (string.IsNullOrWhiteSpace(text)) + return null; + + try + { + return string.Format(text, data); + } + catch (FormatException) + { + Log.Warning(" Key '{Key}' is not properly formatted in '{LanguageName}' response strings", + key, + cultureInfo.Name); + + return $"⚠️ Response string key '{key}' is not properly formatted. Please report this.\n\n{text}"; + } + } + + public CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo) + { + var cmdStrings = _stringsProvider.GetCommandStrings(cultureInfo.Name, commandName); + if (cmdStrings is null) + { + if (cultureInfo.Name == _usCultureInfo.Name) + { + Log.Warning("'{CommandName}' doesn't exist in 'en-US' command strings for one of the marmalades", + commandName); + + return new(null, null); + } + + Log.Information("Missing '{CommandName}' command strings for the '{LocaleName}' locale", + commandName, + cultureInfo.Name); + + return GetCommandStrings(commandName, _usCultureInfo); + } + + return cmdStrings.Value; + } + + public string? GetDescription(CultureInfo? locale = null) + => GetText("marmalades.description", locale ?? _usCultureInfo); + + public static MarmaladeStrings CreateDefault(string basePath) + => new MarmaladeStrings(new LocalMarmaladeStringsProvider(new(basePath))); + + public void Reload() + => _stringsProvider.Reload(); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/StringsLoader.cs b/src/Ellie.Marmalade/Strings/StringsLoader.cs new file mode 100644 index 0000000..bec5c17 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/StringsLoader.cs @@ -0,0 +1,137 @@ +using System.Diagnostics.CodeAnalysis; +using Serilog; +using YamlDotNet.Serialization; + +namespace Ellie.Canary; + +/// +/// Loads strings from the shortcut or localizable path +/// +public class StringsLoader +{ + private readonly string _localizableResponsesPath; + private readonly string _shortcutResponsesFile; + + private readonly string _localizableCommandsPath; + private readonly string _shortcutCommandsFile; + + public StringsLoader(string basePath) + { + _localizableResponsesPath = Path.Join(basePath, "strings/res"); + _shortcutResponsesFile = Path.Join(basePath, "res.yml"); + + _localizableCommandsPath = Path.Join(basePath, "strings/cmds"); + _shortcutCommandsFile = Path.Join(basePath, "cmds.yml"); + } + + public IReadOnlyDictionary> GetCommandStrings() + { + var outputDict = new Dictionary>(); + + if (File.Exists(_shortcutCommandsFile)) + { + if (TryLoadCommandsFromFile(_shortcutCommandsFile, out var dict, out _)) + { + outputDict["en-us"] = dict; + } + + return outputDict; + } + + if (Directory.Exists(_localizableCommandsPath)) + { + foreach (var cmdsFile in Directory.EnumerateFiles(_localizableCommandsPath)) + { + if (TryLoadCommandsFromFile(cmdsFile, out var dict, out var locale) && locale is not null) + { + outputDict[locale.ToLowerInvariant()] = dict; + } + } + } + + return outputDict; + } + + + private static readonly IDeserializer _deserializer = new DeserializerBuilder().Build(); + private static bool TryLoadCommandsFromFile(string file, + [NotNullWhen(true)] out IReadOnlyDictionary? strings, + out string? localeName) + { + try + { + var text = File.ReadAllText(file); + strings = _deserializer.Deserialize?>(text) + ?? new(); + localeName = GetLocaleName(file); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message); + } + + strings = null; + localeName = null; + return false; + } + + + public IReadOnlyDictionary> GetResponseStrings() + { + var outputDict = new Dictionary>(); + + // try to load a shortcut file + if (File.Exists(_shortcutResponsesFile)) + { + if (TryLoadResponsesFromFile(_shortcutResponsesFile, out var dict, out _)) + { + outputDict["en-us"] = dict; + } + + return outputDict; + } + + if (!Directory.Exists(_localizableResponsesPath)) + return outputDict; + + // if shortcut file doesn't exist, try to load localizable files + foreach (var file in Directory.GetFiles(_localizableResponsesPath)) + { + if (TryLoadResponsesFromFile(file, out var strings, out var localeName) && localeName is not null) + { + outputDict[localeName.ToLowerInvariant()] = strings; + } + } + + return outputDict; + } + + private static bool TryLoadResponsesFromFile(string file, + [NotNullWhen(true)] out IReadOnlyDictionary? strings, + out string? localeName) + { + try + { + strings = _deserializer.Deserialize?>(File.ReadAllText(file)); + if (strings is null) + { + localeName = null; + return false; + } + + localeName = GetLocaleName(file).ToLowerInvariant(); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message); + strings = null; + localeName = null; + return false; + } + } + + private static string GetLocaleName(string fileName) + => Path.GetFileNameWithoutExtension(fileName); +} \ No newline at end of file