diff --git a/EllieBot.sln b/EllieBot.sln
index c2985a3..ecc63b7 100644
--- a/EllieBot.sln
+++ b/EllieBot.sln
@@ -11,13 +11,24 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
Dockerfile = Dockerfile
LICENSE = LICENSE
README.md = README.md
+ TODO.md = TODO.md
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot", "src\EllieBot\EllieBot.csproj", "{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ayu", "ayu", "{872A4C63-833C-4AE0-91AB-3CE348D3E6F8}"
+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}") = "Ayu.Discord.Voice", "src\ayu\Ayu.Discord.Voice\Ayu.Discord.Voice.csproj", "{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Coordinator", "src\EllieBot.Coordinator\EllieBot.Coordinator.csproj", "{A631DDF0-3AD1-4CB9-8458-314B1320868A}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Generators", "src\EllieBot.Generators\EllieBot.Generators.csproj", "{CB1A5307-DD85-4795-8A8A-A25D36DADC51}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.VotesApi", "src\EllieBot.VotesApi\EllieBot.VotesApi.csproj", "{F1A77F56-71B0-430E-AE46-94CDD7D43874}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Marmalade", "src\Ellie.Marmalade\Ellie.Marmalade.csproj", "{76AC715D-12FF-4CBE-9585-A861139A2D0C}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Common", "src\Ellie.Common\Ellie.Common.csproj", "{5C1B88B0-B881-4E20-8382-4DDE275F8642}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Econ", "src\Ellie.Econ\Ellie.Econ.csproj", "{A73A6399-50E1-4362-BE29-86C2C88CF05A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -29,18 +40,47 @@ Global
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Release|Any CPU.Build.0 = Release|Any CPU
- {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Release|Any CPU.Build.0 = Release|Any CPU
+ {179DF3B3-AD32-4335-8231-9818338DF3A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {179DF3B3-AD32-4335-8231-9818338DF3A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {179DF3B3-AD32-4335-8231-9818338DF3A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {179DF3B3-AD32-4335-8231-9818338DF3A2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A631DDF0-3AD1-4CB9-8458-314B1320868A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A631DDF0-3AD1-4CB9-8458-314B1320868A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A631DDF0-3AD1-4CB9-8458-314B1320868A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A631DDF0-3AD1-4CB9-8458-314B1320868A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F1A77F56-71B0-430E-AE46-94CDD7D43874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {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
+ {5C1B88B0-B881-4E20-8382-4DDE275F8642}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5C1B88B0-B881-4E20-8382-4DDE275F8642}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5C1B88B0-B881-4E20-8382-4DDE275F8642}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5C1B88B0-B881-4E20-8382-4DDE275F8642}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A73A6399-50E1-4362-BE29-86C2C88CF05A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A73A6399-50E1-4362-BE29-86C2C88CF05A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A73A6399-50E1-4362-BE29-86C2C88CF05A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A73A6399-50E1-4362-BE29-86C2C88CF05A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
- {872A4C63-833C-4AE0-91AB-3CE348D3E6F8} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
- {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067} = {872A4C63-833C-4AE0-91AB-3CE348D3E6F8}
+ {179DF3B3-AD32-4335-8231-9818338DF3A2} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
+ {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}
+ {5C1B88B0-B881-4E20-8382-4DDE275F8642} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
+ {A73A6399-50E1-4362-BE29-86C2C88CF05A} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4}
diff --git a/TODO.md b/TODO.md
index 6227514..15c0a05 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,3 +1,9 @@
# List of things to do
- - Finish the full system rewrite
\ No newline at end of file
+ - Finish the Ellie.Marmalade project
+ - Finish the EllieBot.Tests project
+ - Finish the EllieBot project
+ - Finish the EllieBot.Coordinator project
+ - Finish the EllieBot.Generators project
+ - Finish the EllieBot.Voice project
+ - Finish the EllieBot.VotesApi project
\ No newline at end of file
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..0001d76
--- /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
+{
+
+}
\ 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..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..30fa320
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs
@@ -0,0 +1,22 @@
+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..9b1cc81
--- /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; }
+
+ ///
+ /// 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..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..6fef82c
--- /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 EllieBot
+/// 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..816a95d
--- /dev/null
+++ b/src/Ellie.Marmalade/Context/AnyContext.cs
@@ -0,0 +1,52 @@
+using Discord;
+using EllieBot;
+
+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..33cca4f
--- /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; }
+}
diff --git a/src/Ellie.Marmalade/Ellie.Marmalade.csproj b/src/Ellie.Marmalade/Ellie.Marmalade.csproj
new file mode 100644
index 0000000..aa15438
--- /dev/null
+++ b/src/Ellie.Marmalade/Ellie.Marmalade.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net6.0
+ enable
+ enable
+ preview
+ true
+ Ellie.Marmalade
+
+ 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..c02f973
--- /dev/null
+++ b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs
@@ -0,0 +1,66 @@
+using Discord;
+using Ellie.Marmalade;
+
+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..a174a78
--- /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..5d06f3c
--- /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;
+ }
+}
diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs
new file mode 100644
index 0000000..37c4299
--- /dev/null
+++ b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs
@@ -0,0 +1,15 @@
+using System.Globalization;
+
+namespace Ellie.Marmalade;
+
+///
+/// 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..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..0ea98d5
--- /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..286e9a1
--- /dev/null
+++ b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs
@@ -0,0 +1,79 @@
+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 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..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
diff --git a/src/EllieBot.Coordinator/CoordStartup.cs b/src/EllieBot.Coordinator/CoordStartup.cs
new file mode 100644
index 0000000..4bc262c
--- /dev/null
+++ b/src/EllieBot.Coordinator/CoordStartup.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace EllieBot.Coordinator
+{
+ public class CoordStartup
+ {
+ public IConfiguration Configuration { get; }
+
+ public CoordStartup(IConfiguration config)
+ => Configuration = config;
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddGrpc();
+ services.AddSingleton();
+ services.AddSingleton(
+ serviceProvider => serviceProvider.GetRequiredService());
+ }
+
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+ }
+
+ app.UseRouting();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGrpcService();
+
+ endpoints.MapGet("/",
+ async context =>
+ {
+ await context.Response.WriteAsync(
+ "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
+ });
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj
new file mode 100644
index 0000000..91942cc
--- /dev/null
+++ b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net6.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EllieBot.Coordinator/LogSetup.cs b/src/EllieBot.Coordinator/LogSetup.cs
new file mode 100644
index 0000000..0850b39
--- /dev/null
+++ b/src/EllieBot.Coordinator/LogSetup.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Text;
+using Serilog;
+using Serilog.Events;
+using Serilog.Sinks.SystemConsole.Themes;
+
+namespace EllieBot.Services
+{
+ public static class LogSetup
+ {
+ public static void SetupLogger(object source)
+ {
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
+ .MinimumLevel.Override("System", LogEventLevel.Information)
+ .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
+ .Enrich.FromLogContext()
+ .WriteTo.File("coord.log", LogEventLevel.Information,
+ rollOnFileSizeLimit: true,
+ fileSizeLimitBytes: 10_000_000)
+ .WriteTo.Console(LogEventLevel.Information,
+ theme: GetTheme(),
+ outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] | #{LogSource} | {Message:lj}{NewLine}{Exception}")
+ .Enrich.WithProperty("LogSource", source)
+ .CreateLogger();
+
+ Console.OutputEncoding = Encoding.UTF8;
+ }
+
+ private static ConsoleTheme GetTheme()
+ {
+ if (Environment.OSVersion.Platform == PlatformID.Unix)
+ return AnsiConsoleTheme.Code;
+
+
+#if DEBUG
+ return AnsiConsoleTheme.Code;
+#else
+ return ConsoleTheme.None;
+#endif
+ }
+ }
+}
diff --git a/src/EllieBot.Coordinator/Program.cs b/src/EllieBot.Coordinator/Program.cs
new file mode 100644
index 0000000..6923013
--- /dev/null
+++ b/src/EllieBot.Coordinator/Program.cs
@@ -0,0 +1,20 @@
+using System;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Hosting;
+using EllieBot.Coordinator;
+using EllieBot.Services;
+using Serilog;
+
+// Additional configuration is required to successfully run gRPC on macOS.
+// For instructions on how to configure Kestrel and gRPC clients on macOS, visit https://go.microsoft.com/fwlink/?linkid=2099682
+static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureWebHostDefaults(webBuilder =>
+ {
+ webBuilder.UseStartup();
+ });
+
+LogSetup.SetupLogger("coord");
+Log.Information("Starting coordinator... Pid: {ProcessId}", Environment.ProcessId);
+
+CreateHostBuilder(args).Build().Run();
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/Properties/launchSettings.json b/src/EllieBot.Coordinator/Properties/launchSettings.json
new file mode 100644
index 0000000..1f39bc8
--- /dev/null
+++ b/src/EllieBot.Coordinator/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "EllieBot.Coordinator": {
+ "commandName": "Project",
+ "dotnetRunMessages": "true",
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:3442;https://localhost:3443",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/EllieBot.Coordinator/Protos/coordinator.proto b/src/EllieBot.Coordinator/Protos/coordinator.proto
new file mode 100644
index 0000000..2df14c3
--- /dev/null
+++ b/src/EllieBot.Coordinator/Protos/coordinator.proto
@@ -0,0 +1,127 @@
+syntax = "proto3";
+import "google/protobuf/timestamp.proto";
+
+option csharp_namespace = "EllieBot.Coordinator";
+
+package elliebot;
+
+service Coordinator {
+ // sends update to coordinator to let it know that the shard is alive
+ rpc Heartbeat(HeartbeatRequest) returns (HeartbeatReply);
+ // restarts a shard given the id
+ rpc RestartShard(RestartShardRequest) returns (RestartShardReply);
+ // reshards given the new number of shards
+ rpc Reshard(ReshardRequest) returns (ReshardReply);
+ // Reload config
+ rpc Reload(ReloadRequest) returns (ReloadReply);
+ // Gets status of a single shard
+ rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
+ // Get status of all shards
+ rpc GetAllStatuses(GetAllStatusesRequest) returns (GetAllStatusesReply);
+ // Restarts all shards. Queues them to be restarted at a normal rate. Setting Nuke to true will kill all shards right
+ // away
+ rpc RestartAllShards(RestartAllRequest) returns (RestartAllReply);
+
+ // kill coordinator (and all shards as a consequence)
+ rpc Die(DieRequest) returns (DieReply);
+
+ rpc SetConfigText(SetConfigTextRequest) returns (SetConfigTextReply);
+
+ rpc GetConfigText(GetConfigTextRequest) returns (GetConfigTextReply);
+}
+
+enum ConnState {
+ Disconnected = 0;
+ Connecting = 1;
+ Connected = 2;
+}
+
+message HeartbeatRequest {
+ int32 shardId = 1;
+ int32 guildCount = 2;
+ ConnState state = 3;
+}
+
+message HeartbeatReply {
+ bool gracefulImminent = 1;
+}
+
+message RestartShardRequest {
+ int32 shardId = 1;
+ // should it be queued for restart, set false to kill it and restart immediately with priority
+ bool queue = 2;
+}
+
+message RestartShardReply {
+
+}
+
+message ReshardRequest {
+ int32 shards = 1;
+}
+
+message ReshardReply {
+
+}
+
+message ReloadRequest {
+
+}
+
+message ReloadReply {
+
+}
+
+message GetStatusRequest {
+ int32 shardId = 1;
+}
+
+message GetStatusReply {
+ int32 shardId = 1;
+ ConnState state = 2;
+ int32 guildCount = 3;
+ google.protobuf.Timestamp lastUpdate = 4;
+ bool scheduledForRestart = 5;
+ google.protobuf.Timestamp startedAt = 6;
+}
+
+message GetAllStatusesRequest {
+
+}
+
+message GetAllStatusesReply {
+ repeated GetStatusReply Statuses = 1;
+}
+
+message RestartAllRequest {
+ bool nuke = 1;
+}
+
+message RestartAllReply {
+
+}
+
+message DieRequest {
+ bool graceful = 1;
+}
+
+message DieReply {
+
+}
+
+message GetConfigTextRequest {
+
+}
+
+message GetConfigTextReply {
+ string configYml = 1;
+}
+
+message SetConfigTextRequest {
+ string configYml = 1;
+}
+
+message SetConfigTextReply {
+ bool success = 1;
+ string error = 2;
+}
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/README.md b/src/EllieBot.Coordinator/README.md
new file mode 100644
index 0000000..9a2a993
--- /dev/null
+++ b/src/EllieBot.Coordinator/README.md
@@ -0,0 +1,11 @@
+# Coordinator project
+
+Grpc-based coordinator useful for sharded EllieBot. Its purpose is controlling the lifetime and checking status of the shards it creates.
+
+### Supports
+
+- Checking status
+- Individual shard restarts
+- Full shard restarts
+- Graceful coordinator restarts (restart/update coordinator without killing shards)
+- Kill/Stop
diff --git a/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs b/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs
new file mode 100644
index 0000000..de870ca
--- /dev/null
+++ b/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs
@@ -0,0 +1,457 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Serilog;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Coordinator
+{
+ public sealed class CoordinatorRunner : BackgroundService
+ {
+ private const string CONFIG_PATH = "coord.yml";
+
+ private const string GRACEFUL_STATE_PATH = "graceful.json";
+ private const string GRACEFUL_STATE_BACKUP_PATH = "graceful_old.json";
+
+ private readonly Serializer _serializer;
+ private readonly Deserializer _deserializer;
+
+ private Config _config;
+ private ShardStatus[] _shardStatuses;
+
+ private readonly object locker = new object();
+ private readonly Random _rng;
+ private bool _gracefulImminent;
+
+ public CoordinatorRunner()
+ {
+ _serializer = new();
+ _deserializer = new();
+ _config = LoadConfig();
+ _rng = new Random();
+
+ if (!TryRestoreOldState())
+ InitAll();
+ }
+
+ private Config LoadConfig()
+ {
+ lock (locker)
+ {
+ return _deserializer.Deserialize(File.ReadAllText(CONFIG_PATH));
+ }
+ }
+
+ private void SaveConfig(in Config config)
+ {
+ lock (locker)
+ {
+ var output = _serializer.Serialize(config);
+ File.WriteAllText(CONFIG_PATH, output);
+ }
+ }
+
+ public void ReloadConfig()
+ {
+ lock (locker)
+ {
+ var oldConfig = _config;
+ var newConfig = LoadConfig();
+ if (oldConfig.TotalShards != newConfig.TotalShards)
+ {
+ KillAll();
+ }
+ _config = newConfig;
+ if (oldConfig.TotalShards != newConfig.TotalShards)
+ {
+ InitAll();
+ }
+ }
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // Log.Information("Executing");
+
+ bool first = true;
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ bool hadAction = false;
+ lock (locker)
+ {
+ var shardIds = Enumerable.Range(0, 1) // shard 0 is always first
+ .Append((int)((1173494918812024863 >> 22) % _config.TotalShards)) // then ellie server shard
+ .Concat(Enumerable.Range(1, _config.TotalShards - 1)
+ .OrderBy(_ => _rng.Next())) // then all other shards in a random order
+ .Distinct()
+ .ToList();
+
+ if (first)
+ {
+ // Log.Information("Startup order: {StartupOrder}",string.Join(' ', shardIds));
+ first = false;
+ }
+
+ foreach (var shardId in shardIds)
+ {
+ if (stoppingToken.IsCancellationRequested)
+ break;
+
+ var status = _shardStatuses[shardId];
+
+ if (status.ShouldRestart)
+ {
+ Log.Warning("Shard {ShardId} is restarting (scheduled)...", shardId);
+ hadAction = true;
+ StartShard(shardId);
+ break;
+ }
+
+ if (DateTime.UtcNow - status.LastUpdate >
+ TimeSpan.FromSeconds(_config.UnresponsiveSec))
+ {
+ Log.Warning("Shard {ShardId} is restarting (unresponsive)...", shardId);
+ hadAction = true;
+ StartShard(shardId);
+ break;
+ }
+
+ if (status.StateCounter > 8 && status.State != ConnState.Connected)
+ {
+ Log.Warning("Shard {ShardId} is restarting (stuck)...", shardId);
+ hadAction = true;
+ StartShard(shardId);
+ break;
+ }
+
+ try
+ {
+ if (status.Process is null or { HasExited: true })
+ {
+ Log.Warning("Shard {ShardId} is starting (process)...", shardId);
+ hadAction = true;
+ StartShard(shardId);
+ break;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ Log.Warning("Process for shard {ShardId} is bugged... ", shardId);
+ hadAction = true;
+ StartShard(shardId);
+ break;
+ }
+ }
+ }
+
+ if (hadAction)
+ {
+ await Task.Delay(_config.RecheckIntervalMs, stoppingToken).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error in coordinator: {Message}", ex.Message);
+ }
+
+ await Task.Delay(5000, stoppingToken).ConfigureAwait(false);
+ }
+ }
+
+ private void StartShard(int shardId)
+ {
+ var status = _shardStatuses[shardId];
+ try
+ {
+ status.Process?.Kill(true);
+ }
+ catch
+ {
+ }
+ try
+ {
+ status.Process?.Dispose();
+ }
+ catch
+ {
+ }
+
+ var proc = StartShardProcess(shardId);
+ _shardStatuses[shardId] = status with
+ {
+ Process = proc,
+ LastUpdate = DateTime.UtcNow,
+ State = ConnState.Disconnected,
+ ShouldRestart = false,
+ StateCounter = 0,
+ };
+ }
+
+ private Process StartShardProcess(int shardId)
+ => Process.Start(new ProcessStartInfo()
+ {
+ FileName = _config.ShardStartCommand,
+ Arguments = string.Format(_config.ShardStartArgs,
+ shardId,
+ _config.TotalShards),
+ EnvironmentVariables =
+ {
+ {"ELLIEBOT_IS_COORDINATED", "1"}
+ }
+ // CreateNoWindow = true,
+ // UseShellExecute = false,
+ });
+
+ public bool Heartbeat(int shardId, int guildCount, ConnState state)
+ {
+ lock (locker)
+ {
+ if (shardId >= _shardStatuses.Length)
+ throw new ArgumentOutOfRangeException(nameof(shardId));
+
+ var status = _shardStatuses[shardId];
+ status = _shardStatuses[shardId] = status with
+ {
+ GuildCount = guildCount,
+ State = state,
+ LastUpdate = DateTime.UtcNow,
+ StateCounter = status.State == state
+ ? status.StateCounter + 1
+ : 1
+ };
+ if (status.StateCounter > 1 && status.State == ConnState.Disconnected)
+ {
+ Log.Warning("Shard {ShardId} is in DISCONNECTED state! ({StateCounter})",
+ status.ShardId,
+ status.StateCounter);
+ }
+
+ return _gracefulImminent;
+ }
+ }
+
+ public void SetShardCount(int totalShards)
+ {
+ lock (locker)
+ {
+ SaveConfig(new Config(
+ totalShards,
+ _config.RecheckIntervalMs,
+ _config.ShardStartCommand,
+ _config.ShardStartArgs,
+ _config.UnresponsiveSec));
+ }
+ }
+
+ public void RestartShard(int shardId, bool queue)
+ {
+ lock (locker)
+ {
+ if (shardId >= _shardStatuses.Length)
+ throw new ArgumentOutOfRangeException(nameof(shardId));
+
+ _shardStatuses[shardId] = _shardStatuses[shardId] with
+ {
+ ShouldRestart = true,
+ StateCounter = 0,
+ };
+ }
+ }
+
+ public void RestartAll(bool nuke)
+ {
+ lock (locker)
+ {
+ if (nuke)
+ {
+ KillAll();
+ }
+
+ QueueAll();
+ }
+ }
+
+ private void KillAll()
+ {
+ lock (locker)
+ {
+ for (var shardId = 0; shardId < _shardStatuses.Length; shardId++)
+ {
+ var status = _shardStatuses[shardId];
+ if (status.Process is Process p)
+ {
+ try { p.Kill(); } catch { }
+ try { p.Dispose(); } catch { }
+ _shardStatuses[shardId] = status with
+ {
+ Process = null,
+ ShouldRestart = true,
+ LastUpdate = DateTime.UtcNow,
+ State = ConnState.Disconnected,
+ StateCounter = 0,
+ };
+ }
+ }
+ }
+ }
+
+ public void SaveState()
+ {
+ var coordState = new CoordState()
+ {
+ StatusObjects = _shardStatuses
+ .Select(x => new JsonStatusObject()
+ {
+ Pid = x.Process?.Id,
+ ConnectionState = x.State,
+ GuildCount = x.GuildCount,
+ })
+ .ToList()
+ };
+ var jsonState = JsonSerializer.Serialize(coordState, new JsonSerializerOptions()
+ {
+ WriteIndented = true,
+ });
+ File.WriteAllText(GRACEFUL_STATE_PATH, jsonState);
+ }
+ private bool TryRestoreOldState()
+ {
+ lock (locker)
+ {
+ if (!File.Exists(GRACEFUL_STATE_PATH))
+ return false;
+
+ Log.Information("Restoring old coordinator state...");
+
+ CoordState savedState;
+ try
+ {
+ savedState = JsonSerializer.Deserialize(File.ReadAllText(GRACEFUL_STATE_PATH));
+
+ if (savedState is null)
+ throw new Exception("Old state is null?!");
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error deserializing old state: {Message}", ex.Message);
+ File.Move(GRACEFUL_STATE_PATH, GRACEFUL_STATE_BACKUP_PATH, overwrite: true);
+ return false;
+ }
+
+ if (savedState.StatusObjects.Count != _config.TotalShards)
+ {
+ Log.Error("Unable to restore old state because shard count doesn't match");
+ File.Move(GRACEFUL_STATE_PATH, GRACEFUL_STATE_BACKUP_PATH, overwrite: true);
+ return false;
+ }
+
+ _shardStatuses = new ShardStatus[_config.TotalShards];
+
+ for (int shardId = 0; shardId < _shardStatuses.Length; shardId++)
+ {
+ var statusObj = savedState.StatusObjects[shardId];
+ Process p = null;
+ if (statusObj.Pid is { } pid)
+ {
+ try
+ {
+ p = Process.GetProcessById(pid);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Process for shard {ShardId} is not runnning", shardId);
+ }
+ }
+
+ _shardStatuses[shardId] = new(
+ shardId,
+ DateTime.UtcNow,
+ statusObj.GuildCount,
+ statusObj.ConnectionState,
+ p is null,
+ p);
+ }
+
+ File.Move(GRACEFUL_STATE_PATH, GRACEFUL_STATE_BACKUP_PATH, overwrite: true);
+ Log.Information("Old state restored!");
+ return true;
+ }
+ }
+
+ private void InitAll()
+ {
+ lock (locker)
+ {
+ _shardStatuses = new ShardStatus[_config.TotalShards];
+ for (var shardId = 0; shardId < _shardStatuses.Length; shardId++)
+ {
+ _shardStatuses[shardId] = new ShardStatus(shardId, DateTime.UtcNow);
+ }
+ }
+ }
+
+ private void QueueAll()
+ {
+ lock (locker)
+ {
+ for (var shardId = 0; shardId < _shardStatuses.Length; shardId++)
+ {
+ _shardStatuses[shardId] = _shardStatuses[shardId] with
+ {
+ ShouldRestart = true
+ };
+ }
+ }
+ }
+
+
+ public ShardStatus GetShardStatus(int shardId)
+ {
+ lock (locker)
+ {
+ if (shardId >= _shardStatuses.Length)
+ throw new ArgumentOutOfRangeException(nameof(shardId));
+
+ return _shardStatuses[shardId];
+ }
+ }
+
+ public List GetAllStatuses()
+ {
+ lock (locker)
+ {
+ var toReturn = new List(_shardStatuses.Length);
+ toReturn.AddRange(_shardStatuses);
+ return toReturn;
+ }
+ }
+
+ public void PrepareGracefulShutdown()
+ {
+ lock (locker)
+ {
+ _gracefulImminent = true;
+ }
+ }
+
+ public string GetConfigText()
+ => File.ReadAllText(CONFIG_PATH);
+
+ public void SetConfigText(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ throw new ArgumentNullException(nameof(text), "coord.yml can't be empty");
+ var config = _deserializer.Deserialize(text);
+ SaveConfig(in config);
+ ReloadConfig();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/Services/CoordinatorService.cs b/src/EllieBot.Coordinator/Services/CoordinatorService.cs
new file mode 100644
index 0000000..8666736
--- /dev/null
+++ b/src/EllieBot.Coordinator/Services/CoordinatorService.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Threading.Tasks;
+using Google.Protobuf.WellKnownTypes;
+using Grpc.Core;
+
+namespace EllieBot.Coordinator
+{
+ public sealed class CoordinatorService : Coordinator.CoordinatorBase
+ {
+ private readonly CoordinatorRunner _runner;
+
+ public CoordinatorService(CoordinatorRunner runner)
+ => _runner = runner;
+
+ public override Task Heartbeat(HeartbeatRequest request, ServerCallContext context)
+ {
+ var gracefulImminent = _runner.Heartbeat(request.ShardId, request.GuildCount, request.State);
+ return Task.FromResult(new HeartbeatReply()
+ {
+ GracefulImminent = gracefulImminent
+ });
+ }
+
+ public override Task Reshard(ReshardRequest request, ServerCallContext context)
+ {
+ _runner.SetShardCount(request.Shards);
+ return Task.FromResult(new ReshardReply());
+ }
+
+ public override Task RestartShard(RestartShardRequest request, ServerCallContext context)
+ {
+ _runner.RestartShard(request.ShardId, request.Queue);
+ return Task.FromResult(new RestartShardReply());
+ }
+
+ public override Task Reload(ReloadRequest request, ServerCallContext context)
+ {
+ _runner.ReloadConfig();
+ return Task.FromResult(new ReloadReply());
+ }
+
+ public override Task GetStatus(GetStatusRequest request, ServerCallContext context)
+ {
+ var status = _runner.GetShardStatus(request.ShardId);
+
+
+ return Task.FromResult(StatusToStatusReply(status));
+ }
+
+ public override Task GetAllStatuses(GetAllStatusesRequest request,
+ ServerCallContext context)
+ {
+ var statuses = _runner
+ .GetAllStatuses();
+
+ var reply = new GetAllStatusesReply();
+ foreach (var status in statuses)
+ reply.Statuses.Add(StatusToStatusReply(status));
+
+ return Task.FromResult(reply);
+ }
+
+ private static GetStatusReply StatusToStatusReply(ShardStatus status)
+ {
+ DateTime startTime;
+ try
+ {
+ startTime = status.Process is null or { HasExited: true }
+ ? DateTime.MinValue.ToUniversalTime()
+ : status.Process.StartTime.ToUniversalTime();
+ }
+ catch
+ {
+ startTime = DateTime.MinValue.ToUniversalTime();
+ }
+
+ var reply = new GetStatusReply()
+ {
+ State = status.State,
+ GuildCount = status.GuildCount,
+ ShardId = status.ShardId,
+ LastUpdate = Timestamp.FromDateTime(status.LastUpdate),
+ ScheduledForRestart = status.ShouldRestart,
+ StartedAt = Timestamp.FromDateTime(startTime)
+ };
+
+ return reply;
+ }
+
+ public override Task RestartAllShards(RestartAllRequest request, ServerCallContext context)
+ {
+ _runner.RestartAll(request.Nuke);
+ return Task.FromResult(new RestartAllReply());
+ }
+
+ public override async Task Die(DieRequest request, ServerCallContext context)
+ {
+ if (request.Graceful)
+ {
+ _runner.PrepareGracefulShutdown();
+ await Task.Delay(10_000);
+ }
+
+ _runner.SaveState();
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(250);
+ Environment.Exit(0);
+ });
+
+ return new DieReply();
+ }
+
+ public override Task SetConfigText(SetConfigTextRequest request, ServerCallContext context)
+ {
+ var error = string.Empty;
+ var success = true;
+ try
+ {
+ _runner.SetConfigText(request.ConfigYml);
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ success = false;
+ }
+
+ return Task.FromResult(new(new()
+ {
+ Success = success,
+ Error = error
+ }));
+ }
+
+ public override Task GetConfigText(GetConfigTextRequest request, ServerCallContext context)
+ {
+ var text = _runner.GetConfigText();
+ return Task.FromResult(new GetConfigTextReply()
+ {
+ ConfigYml = text,
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/Shared/Config.cs b/src/EllieBot.Coordinator/Shared/Config.cs
new file mode 100644
index 0000000..af22c33
--- /dev/null
+++ b/src/EllieBot.Coordinator/Shared/Config.cs
@@ -0,0 +1,21 @@
+namespace EllieBot.Coordinator
+{
+ public readonly struct Config
+ {
+ public int TotalShards { get; init; }
+ public int RecheckIntervalMs { get; init; }
+ public string ShardStartCommand { get; init; }
+ public string ShardStartArgs { get; init; }
+ public double UnresponsiveSec { get; init; }
+
+ public Config(int totalShards, int recheckIntervalMs, string shardStartCommand, string shardStartArgs, double unresponsiveSec)
+ {
+ TotalShards = totalShards;
+ RecheckIntervalMs = recheckIntervalMs;
+ ShardStartCommand = shardStartCommand;
+ ShardStartArgs = shardStartArgs;
+ UnresponsiveSec = unresponsiveSec;
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/Shared/CoordState.cs b/src/EllieBot.Coordinator/Shared/CoordState.cs
new file mode 100644
index 0000000..c9dfef8
--- /dev/null
+++ b/src/EllieBot.Coordinator/Shared/CoordState.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace EllieBot.Coordinator
+{
+ public class CoordState
+ {
+ public List StatusObjects { get; init; }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/Shared/JsonStatusObject.cs b/src/EllieBot.Coordinator/Shared/JsonStatusObject.cs
new file mode 100644
index 0000000..5de659f
--- /dev/null
+++ b/src/EllieBot.Coordinator/Shared/JsonStatusObject.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Coordinator
+{
+ public class JsonStatusObject
+ {
+ public int? Pid { get; init; }
+ public int GuildCount { get; init; }
+ public ConnState ConnectionState { get; init; }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/Shared/ShardStatus.cs b/src/EllieBot.Coordinator/Shared/ShardStatus.cs
new file mode 100644
index 0000000..d2deb0b
--- /dev/null
+++ b/src/EllieBot.Coordinator/Shared/ShardStatus.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Diagnostics;
+
+namespace EllieBot.Coordinator
+{
+ public sealed record ShardStatus(
+ int ShardId,
+ DateTime LastUpdate,
+ int GuildCount = 0,
+ ConnState State = ConnState.Disconnected,
+ bool ShouldRestart = false,
+ Process Process = null,
+ int StateCounter = 0
+ );
+}
\ No newline at end of file
diff --git a/src/EllieBot.Coordinator/appsettings.Development.json b/src/EllieBot.Coordinator/appsettings.Development.json
new file mode 100644
index 0000000..8983e0f
--- /dev/null
+++ b/src/EllieBot.Coordinator/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/src/EllieBot.Coordinator/appsettings.json b/src/EllieBot.Coordinator/appsettings.json
new file mode 100644
index 0000000..7e5ece7
--- /dev/null
+++ b/src/EllieBot.Coordinator/appsettings.json
@@ -0,0 +1,20 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "AllowedHosts": "*",
+ "Kestrel": {
+ "EndpointDefaults": {
+ "Protocols": "Http2"
+ },
+ "Endpoints": {
+ "Http": {
+ "Url": "http://localhost:3442"
+ }
+ }
+ }
+}
diff --git a/src/EllieBot.Coordinator/coord.yml b/src/EllieBot.Coordinator/coord.yml
new file mode 100644
index 0000000..02587a2
--- /dev/null
+++ b/src/EllieBot.Coordinator/coord.yml
@@ -0,0 +1,12 @@
+# total number of shards
+TotalShards: 3
+# How often do shards ping their state back to the coordinator
+RecheckIntervalMs: 5000
+# Command to run the shard
+ShardStartCommand: dotnet
+# Arguments to run the shard
+# {0} = shard id
+# {1} = total number of shards
+ShardStartArgs: run -p "..\EllieBot\EllieBot.csproj" --no-build -- {0} {1}
+# How long does it take for the shard to be forcefully restarted once it stops reporting its state
+UnresponsiveSec: 30
diff --git a/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs b/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs
new file mode 100644
index 0000000..6bd7da4
--- /dev/null
+++ b/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs
@@ -0,0 +1,254 @@
+// Code temporarily yeeted from
+// https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs
+// because of NRT issue
+#nullable enable
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace Cloneable
+{
+ [Generator]
+ public class CloneableGenerator : ISourceGenerator
+ {
+ private const string PREVENT_DEEP_COPY_KEY_STRING = "PreventDeepCopy";
+ private const string EXPLICIT_DECLARATION_KEY_STRING = "ExplicitDeclaration";
+
+ private const string CLONEABLE_NAMESPACE = "Cloneable";
+ private const string CLONEABLE_ATTRIBUTE_STRING = "CloneableAttribute";
+ private const string CLONE_ATTRIBUTE_STRING = "CloneAttribute";
+ private const string IGNORE_CLONE_ATTRIBUTE_STRING = "IgnoreCloneAttribute";
+
+ private const string CLONEABLE_ATTRIBUTE_TEXT = @"//
+using System;
+
+namespace " + CLONEABLE_NAMESPACE + @"
+{
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = true, AllowMultiple = false)]
+ public sealed class " + CLONEABLE_ATTRIBUTE_STRING + @" : Attribute
+ {
+ public " + CLONEABLE_ATTRIBUTE_STRING + @"()
+ {
+ }
+
+ public bool " + EXPLICIT_DECLARATION_KEY_STRING + @" { get; set; }
+ }
+}
+";
+
+ private const string CLONE_PROPERTY_ATTRIBUTE_TEXT = @"//
+using System;
+
+namespace " + CLONEABLE_NAMESPACE + @"
+{
+ [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
+ public sealed class " + CLONE_ATTRIBUTE_STRING + @" : Attribute
+ {
+ public " + CLONE_ATTRIBUTE_STRING + @"()
+ {
+ }
+
+ public bool " + PREVENT_DEEP_COPY_KEY_STRING + @" { get; set; }
+ }
+}
+";
+
+ private const string IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT = @"//
+using System;
+
+namespace " + CLONEABLE_NAMESPACE + @"
+{
+ [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
+ public sealed class " + IGNORE_CLONE_ATTRIBUTE_STRING + @" : Attribute
+ {
+ public " + IGNORE_CLONE_ATTRIBUTE_STRING + @"()
+ {
+ }
+ }
+}
+";
+
+ private INamedTypeSymbol? _cloneableAttribute;
+ private INamedTypeSymbol? _ignoreCloneAttribute;
+ private INamedTypeSymbol? _cloneAttribute;
+
+ public void Initialize(GeneratorInitializationContext context)
+ => context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
+
+ public void Execute(GeneratorExecutionContext context)
+ {
+ InjectCloneableAttributes(context);
+ GenerateCloneMethods(context);
+ }
+
+ private void GenerateCloneMethods(GeneratorExecutionContext context)
+ {
+ if (context.SyntaxReceiver is not SyntaxReceiver receiver)
+ return;
+
+ Compilation compilation = GetCompilation(context);
+
+ InitAttributes(compilation);
+
+ var classSymbols = GetClassSymbols(compilation, receiver);
+ foreach (var classSymbol in classSymbols)
+ {
+ if (!classSymbol.TryGetAttribute(_cloneableAttribute!, out var attributes))
+ continue;
+
+ var attribute = attributes.Single();
+ var isExplicit = (bool?)attribute.NamedArguments.FirstOrDefault(e => e.Key.Equals(EXPLICIT_DECLARATION_KEY_STRING)).Value.Value ?? false;
+ context.AddSource($"{classSymbol.Name}_cloneable.g.cs", SourceText.From(CreateCloneableCode(classSymbol, isExplicit), Encoding.UTF8));
+ }
+ }
+
+ private void InitAttributes(Compilation compilation)
+ {
+ _cloneableAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{CLONEABLE_ATTRIBUTE_STRING}")!;
+ _cloneAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{CLONE_ATTRIBUTE_STRING}")!;
+ _ignoreCloneAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{IGNORE_CLONE_ATTRIBUTE_STRING}")!;
+ }
+
+ private static Compilation GetCompilation(GeneratorExecutionContext context)
+ {
+ var options = context.Compilation.SyntaxTrees.First().Options as CSharpParseOptions;
+
+ var compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(CLONEABLE_ATTRIBUTE_TEXT, Encoding.UTF8), options)).
+ AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8), options)).
+ AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8), options));
+ return compilation;
+ }
+
+ private string CreateCloneableCode(INamedTypeSymbol classSymbol, bool isExplicit)
+ {
+ string namespaceName = classSymbol.ContainingNamespace.ToDisplayString();
+ var fieldAssignmentsCode = GenerateFieldAssignmentsCode(classSymbol, isExplicit).ToList();
+ var fieldAssignmentsCodeSafe = fieldAssignmentsCode.Select(x =>
+ {
+ if (x.isCloneable)
+ return x.line + "Safe(referenceChain)";
+ return x.line;
+ });
+ var fieldAssignmentsCodeFast = fieldAssignmentsCode.Select(x =>
+ {
+ if (x.isCloneable)
+ return x.line + "()";
+ return x.line;
+ });
+
+ return $@"using System.Collections.Generic;
+
+namespace {namespaceName}
+{{
+ {GetAccessModifier(classSymbol)} partial class {classSymbol.Name}
+ {{
+ ///
+ /// Creates a copy of {classSymbol.Name} with NO circular reference checking. This method should be used if performance matters.
+ ///
+ /// Will occur on any object that has circular references in the hierarchy.
+ ///
+ public {classSymbol.Name} Clone()
+ {{
+ return new {classSymbol.Name}
+ {{
+{string.Join(",\n", fieldAssignmentsCodeFast)}
+ }};
+ }}
+
+ ///
+ /// Creates a copy of {classSymbol.Name} with circular reference checking. If a circular reference was detected, only a reference of the leaf object is passed instead of cloning it.
+ ///
+ /// Should only be provided if specific objects should not be cloned but passed by reference instead.
+ public {classSymbol.Name} CloneSafe(Stack