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