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