diff --git a/Ellie.sln b/Ellie.sln index dc58a90..25f5aa9 100644 --- a/Ellie.sln +++ b/Ellie.sln @@ -5,11 +5,15 @@ VisualStudioVersion = 17.6.33815.320 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie", "src\Ellie\Ellie.csproj", "{2BAF005E-781D-45FF-B218-E6361F5E8CD4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie", "src\Ellie\Ellie.csproj", "{2BAF005E-781D-45FF-B218-E6361F5E8CD4}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ayu", "ayu", "{5284415D-A43F-4539-9483-410124199743}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ayu.Discord.Voice", "src\ayu\Ayu.Discord.Voice\Ayu.Discord.Voice.csproj", "{34E6D136-B151-4B6E-A8E7-7A2FB7B06CA3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ayu.Discord.Voice", "src\ayu\Ayu.Discord.Voice\Ayu.Discord.Voice.csproj", "{34E6D136-B151-4B6E-A8E7-7A2FB7B06CA3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Tests", "src\Ellie.Tests\Ellie.Tests.csproj", "{6A8CE149-3808-474F-A2E6-B89825BB5DC2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Marmalade", "src\Ellie.Marmalade\Ellie.Marmalade.csproj", "{D6CF9ABE-205E-4699-90CA-0F18ED236490}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -25,6 +29,14 @@ Global {34E6D136-B151-4B6E-A8E7-7A2FB7B06CA3}.Debug|Any CPU.Build.0 = Debug|Any CPU {34E6D136-B151-4B6E-A8E7-7A2FB7B06CA3}.Release|Any CPU.ActiveCfg = Release|Any CPU {34E6D136-B151-4B6E-A8E7-7A2FB7B06CA3}.Release|Any CPU.Build.0 = Release|Any CPU + {6A8CE149-3808-474F-A2E6-B89825BB5DC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A8CE149-3808-474F-A2E6-B89825BB5DC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A8CE149-3808-474F-A2E6-B89825BB5DC2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A8CE149-3808-474F-A2E6-B89825BB5DC2}.Release|Any CPU.Build.0 = Release|Any CPU + {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -33,6 +45,8 @@ Global {2BAF005E-781D-45FF-B218-E6361F5E8CD4} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {5284415D-A43F-4539-9483-410124199743} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {34E6D136-B151-4B6E-A8E7-7A2FB7B06CA3} = {5284415D-A43F-4539-9483-410124199743} + {6A8CE149-3808-474F-A2E6-B89825BB5DC2} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} + {D6CF9ABE-205E-4699-90CA-0F18ED236490} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {878761F1-C7B5-4D38-A00D-3377D703EBBA} diff --git a/src/Ellie.Marmalade/Attributes/FilterAttribute.cs b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs new file mode 100644 index 0000000..de6ae24 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs @@ -0,0 +1,10 @@ +namespace Ellie.Marmalade; + +/// +/// 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..568cc3a --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs @@ -0,0 +1,10 @@ +namespace Ellie.Marmalade; + +/// +/// Used as a marker class for bot_perm and user_perm Attributes +/// Has no functionality +/// +public abstract class MarmaladePermAttribute : Attribute +{ + +} 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..8186614 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs @@ -0,0 +1,7 @@ +namespace Ellie.Marmalade; + +[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..d1f22ee --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs @@ -0,0 +1,23 @@ +using Discord; + +namespace Ellie.Marmalade; + +[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..56ce03b --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/cmdAttribute.cs @@ -0,0 +1,37 @@ +namespace Ellie.Marmalade; + +/// +/// 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..4865cff --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/injectAttribute.cs @@ -0,0 +1,10 @@ +namespace Ellie.Marmalade; + +/// +/// 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..b16c225 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs @@ -0,0 +1,10 @@ +namespace Ellie.Marmalade; + +/// +/// 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..f544a08 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/prioAttribute.cs @@ -0,0 +1,20 @@ +namespace Ellie.Marmalade; + +/// +/// 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; } + + /// + /// Canary 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..dab065f --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/svcAttribute.cs @@ -0,0 +1,23 @@ +namespace Ellie.Marmalade; + +/// +/// 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..7d195eb --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/user_permAttribute.cs @@ -0,0 +1,22 @@ +using Discord; + +namespace Ellie.Marmalade; + +[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..80aab59 --- /dev/null +++ b/src/Ellie.Marmalade/Canary.cs @@ -0,0 +1,143 @@ +using Discord; + +namespace Ellie.Marmalade; + +/// +/// The base class which will be loaded as a module into Ellie +/// Any user-defined canary has to inherit from this class. +/// Canaries get instantiated ONLY ONCE during the loading, +/// and any canary commands will be executed on the same instance. +/// +public abstract class Canary : IAsyncDisposable +{ + /// + /// Name of the canary. 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 canary 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 canary 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 canary as this method + /// will be called when *any* command from any module or canary was found. + /// You can choose to prevent the execution of the command by returning "true" value. + /// Execution order: + /// + /// → + /// → + /// ** → + /// OR + /// + /// + /// Command context + /// Name of the canary 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..e37a1d0 --- /dev/null +++ b/src/Ellie.Marmalade/Context/AnyContext.cs @@ -0,0 +1,52 @@ +using Discord; +using Ellie; + +namespace Ellie.Marmalade; + +/// +/// 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 marmalade + /// + 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..a703fa4 --- /dev/null +++ b/src/Ellie.Marmalade/Context/DmContext.cs @@ -0,0 +1,11 @@ +using Discord; + +namespace Ellie.Marmalade; + +/// +/// 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..789e606 --- /dev/null +++ b/src/Ellie.Marmalade/Context/GuildContext.cs @@ -0,0 +1,12 @@ +using Discord; + +namespace Ellie.Marmalade; + +/// +/// 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; } +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Ellie.Marmalade.csproj b/src/Ellie.Marmalade/Ellie.Marmalade.csproj new file mode 100644 index 0000000..8568c8d --- /dev/null +++ b/src/Ellie.Marmalade/Ellie.Marmalade.csproj @@ -0,0 +1,21 @@ + + + + net7.0 + enable + enable + + Toastie_t0ast + + + + + + + + + + 6.0.0 + + + diff --git a/src/Ellie.Marmalade/EmbedColor.cs b/src/Ellie.Marmalade/EmbedColor.cs new file mode 100644 index 0000000..432122e --- /dev/null +++ b/src/Ellie.Marmalade/EmbedColor.cs @@ -0,0 +1,8 @@ +namespace Ellie; + +public enum EmbedColor +{ + Ok, + Pending, + Error +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs b/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs new file mode 100644 index 0000000..00aebf9 --- /dev/null +++ b/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs @@ -0,0 +1,13 @@ +namespace Ellie; + +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); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs new file mode 100644 index 0000000..66c6750 --- /dev/null +++ b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs @@ -0,0 +1,66 @@ +using Discord; +using Ellie.Marmalade; + +namespace Ellie; + +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)}"); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/IEmbedBuilder.cs b/src/Ellie.Marmalade/IEmbedBuilder.cs new file mode 100644 index 0000000..cd577ad --- /dev/null +++ b/src/Ellie.Marmalade/IEmbedBuilder.cs @@ -0,0 +1,18 @@ +using Discord; + +namespace Ellie; + +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); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/ParamParser/ParamParser.cs b/src/Ellie.Marmalade/ParamParser/ParamParser.cs new file mode 100644 index 0000000..7fb6480 --- /dev/null +++ b/src/Ellie.Marmalade/ParamParser/ParamParser.cs @@ -0,0 +1,16 @@ +namespace Ellie.Marmalade; + +/// +/// 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..e12b77e --- /dev/null +++ b/src/Ellie.Marmalade/ParamParser/ParseResult.cs @@ -0,0 +1,48 @@ +namespace Ellie.Marmalade; + +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..73c75a7 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/CommandStrings.cs @@ -0,0 +1,24 @@ +using YamlDotNet.Serialization; + +namespace Ellie.Marmalade; + +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; + } +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs new file mode 100644 index 0000000..b9ac3bb --- /dev/null +++ b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs @@ -0,0 +1,15 @@ +using System.Globalization; + +namespace Ellie.Marmalade; + +/// +/// Defins 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); +} diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs new file mode 100644 index 0000000..6df86b0 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs @@ -0,0 +1,28 @@ +namespace Ellie.Marmalade; + +/// +/// 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..019e28f --- /dev/null +++ b/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs @@ -0,0 +1,40 @@ +namespace Ellie.Marmalade; + +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..57f3e5b --- /dev/null +++ b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using Serilog; + +namespace Ellie.Marmalade; + +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 medusae", + 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("marmalade.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..986fcad --- /dev/null +++ b/src/Ellie.Marmalade/Strings/StringsLoader.cs @@ -0,0 +1,137 @@ +using System.Diagnostics.CodeAnalysis; +using Serilog; +using YamlDotNet.Serialization; + +namespace Ellie.Marmalade; + +/// +/// 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