diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c84eea1
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+# Ignore all files
+*
+
+# Don't ignore nugetconfig
+!./NuGet.Config
+
+# Don't ignore src projects
+!src/**
+!docker-entrypoint.sh
+
+# ignore bin and obj folders in projects
+src/**/bin/*
+src/**/obj/*
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index e69de29..dcdedcf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -0,0 +1,46 @@
+FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
+WORKDIR /source
+
+COPY src/Ellie.Marmalade/*.csproj src/Ellie.Marmalade/
+COPY src/EllieBot/*.csproj src/EllieBot/
+COPY src/EllieBot.Coordinator/*.csproj src/EllieBot.Coordinator/
+COPY src/EllieBot.Generators/*.csproj src/EllieBot.Generators/
+COPY src/EllieBot.Voice/*.csproj src/EllieBot.Voice/
+COPY NuGet.Config ./
+RUN dotnet restore src/EllieBot/
+
+COPY . .
+WORKDIR /source/src/EllieBot
+RUN set -xe; \
+ dotnet --version; \
+ dotnet publish -c Release -o /app --no-restore; \
+ mv /app/data /app/data_init; \
+ rm -Rf libopus* libsodium* opus.* runtimes/win* runtimes/osx* runtimes/linux-arm* runtimes/linux-mips*; \
+ find /app -type f -exec chmod -x {} \; ;\
+ chmod +x /app/EllieBot
+
+# final stage/image
+FROM mcr.microsoft.com/dotnet/runtime:6.0
+WORKDIR /app
+
+RUN set -xe; \
+ useradd -m ellie; \
+ apt-get update; \
+ apt-get install -y --no-install-recommends libopus0 libsodium23 libsqlite3-0 curl ffmpeg python3 sudo; \
+ update-alternatives --install /usr/bin/python python /usr/bin/python3.9 1; \
+ echo 'Defaults>ellie env_keep+="ASPNETCORE_* DOTNET_* EllieBot_* shard_id total_shards TZ"' > /etc/sudoers.d/ellie; \
+ curl -Lo /usr/local/bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp; \
+ chmod a+rx /usr/local/bin/yt-dlp; \
+ apt-get autoremove -y; \
+ apt-get autoclean -y
+
+COPY --from=build /app ./
+COPY docker-entrypoint.sh /usr/local/sbin
+
+ENV shard_id=0
+ENV total_shards=1
+ENV EllieBot__creds=/app/data/creds.yml
+
+VOLUME [" /app/data "]
+ENTRYPOINT [ "/usr/local/sbin/docker-entrypoint.sh" ]
+CMD dotnet EllieBot.dll "$shard_id" "$total_shards"
diff --git a/EllieBot.sln b/EllieBot.sln
index c2985a3..4a3f164 100644
--- a/EllieBot.sln
+++ b/EllieBot.sln
@@ -8,16 +8,28 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6C633450-E6C2-47ED-A7AA-7367232F703A}"
ProjectSection(SolutionItems) = preProject
CHANGELOG.md = CHANGELOG.md
- Dockerfile = Dockerfile
LICENSE = LICENSE
README.md = README.md
+ Dockerfile = Dockerfile
+ NuGet.Config = NuGet.Config
+ migrate.ps1 = migrate.ps1
+ remove-migrations.ps1 = remove-migrations.ps1
+ TODO.md = TODO.md
EndProjectSection
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot", "src\EllieBot\EllieBot.csproj", "{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot", "src\EllieBot\EllieBot.csproj", "{4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}"
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}") = "EllieBot.Voice", "src\EllieBot.Voice\EllieBot.Voice.csproj", "{1D93CE3C-80B4-49C7-A9A2-99988920AAEC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -25,22 +37,46 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {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
+ {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.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
+ {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.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}
+ {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
+ {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}
+ {1D93CE3C-80B4-49C7-A9A2-99988920AAEC} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4}
diff --git a/NuGet.Config b/NuGet.Config
new file mode 100644
index 0000000..7e64704
--- /dev/null
+++ b/NuGet.Config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 0f9c1bd..452254f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Ellie
+[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page)
+
## Small disclaimer
All the code in this repo may not be production ready yet and if you want to try and run a version of this by yourself you are on your own.
diff --git a/TODO.md b/TODO.md
index 6227514..5b6c748 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~~ Done
+ - ~~Finish the EllieBot.Tests project~~ Done
+ - ~~Finish the EllieBot project~~ Done
+ - ~~Finish the EllieBot.Coordinator project~~ Done
+ - ~~Finish the EllieBot.Generators project~~ Done
+ - ~~Finish the EllieBot.Voice project~~ Done
+ - ~~Finish the EllieBot.VotesApi project~~ Done
\ No newline at end of file
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 0000000..3f3fbd3
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+set -e;
+
+data_init=/app/data_init
+data=/app/data
+
+# populate /app/data if empty
+for i in $(ls $data_init)
+do
+ if [ ! -e "$data/$i" ]; then
+ [ -f "$data_init/$i" ] && cp "$data_init/$i" "$data/$i"
+ [ -d "$data_init/$i" ] && cp -r "$data_init/$i" "$data/$i"
+ fi
+done
+
+# creds.yml migration
+if [ -f /app/creds.yml ]; then
+ echo "Default location for creds.yml is now /app/data/creds.yml."
+ echo "Please move your creds.yml and update your docker-compose.yml accordingly."
+
+ export Ellie_creds=/app/creds.yml
+fi
+
+# ensure ellie can write on /app/data
+chown -R ellie:ellie "$data"
+
+# drop to regular user and launch command
+exec sudo -u ellie "$@"
\ No newline at end of file
diff --git a/exe_builder.iss b/exe_builder.iss
new file mode 100644
index 0000000..9ef8bb9
--- /dev/null
+++ b/exe_builder.iss
@@ -0,0 +1,74 @@
+#define sysfolder "system"
+#define version GetEnv("ELLIEBOT_INSTALL_VERSION")
+#define target "win-x64"
+#define platform "net8.0"
+
+[Setup]
+AppName = {param:botname|EllieBot}
+AppVersion={#version}
+AppPublisher=Toastie
+DefaultDirName={param:installpath|{commonpf}\EllieBot}
+DefaultGroupName=EllieBot
+UninstallDisplayIcon={app}\{#sysfolder}\ellie_icon.ico
+Compression=lzma2
+SolidCompression=yes
+UsePreviousLanguage=no
+UsePreviousSetupType=no
+UsePreviousAppDir=no
+OutputDir=ellie-installers/{#version}/
+OutputBaseFilename=ellie-setup-{#version}
+AppReadmeFile=https://commands.elliebot.net/
+ArchitecturesInstallIn64BitMode=x64
+DisableWelcomePage=yes
+DisableDirPage=yes
+DisableFinishedPage=yes
+DisableReadyMemo=yes
+DisableProgramGroupPage=yes
+WizardStyle=modern
+UpdateUninstallLogAppName=no
+CreateUninstallRegKey=no
+Uninstallable=no
+
+[Files]
+;install
+Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\*"; DestDir: "{app}\{#sysfolder}"; Permissions: users-full; Flags: recursesubdirs onlyifdoesntexist ignoreversion createallsubdirs; Excludes: "*.pdb, *.db"
+
+;reinstall - i want to copy all files, but i don't want to overwrite any data files because users will lose their customization if they don't have a backup,
+; and i don't want them to have to backup and then copy-merge into data folder themselves, or lose their currency images due to overwrite.
+Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\*"; DestDir: "{app}\{#sysfolder}"; Permissions: users-full; Flags: recursesubdirs ignoreversion onlyifdestfileexists createallsubdirs; Excludes: "*.pdb, *.db, data\*, credentials.json, creds.yml";
+Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\data\*"; DestDir: "{app}\{#sysfolder}\data"; Permissions: users-full; Flags: recursesubdirs onlyifdoesntexist createallsubdirs;
+; overwrite strings and aliases
+Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\data\aliases.yml"; DestDir: "{app}\{#sysfolder}\data\"; Permissions: users-full; Flags: recursesubdirs ignoreversion onlyifdestfileexists createallsubdirs;
+Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\data\strings\*"; DestDir: "{app}\{#sysfolder}\data\strings"; Permissions: users-full; Flags: recursesubdirs ignoreversion onlyifdestfileexists createallsubdirs;
+
+[Dirs]
+Name:"{app}\{#sysfolder}\data"; Permissions: everyone-modify
+Name:"{app}\{#sysfolder}\config"; Permissions: everyone-modify
+Name:"{app}\{#sysfolder}"; Permissions: everyone-modify
+
+; [Run]
+; Filename: "https://docs.elliebot.net/ellie/"; Flags: postinstall shellexec runasoriginaluser; Description: "Open setup guide"
+; Filename: "{app}\{#sysfolder}\creds.yml"; Flags: postinstall shellexec runasoriginaluser; Description: "Open creds file"
+
+[Icons]
+; for pretty install directory
+Name: "{app}\EllieBot"; Filename: "{app}\{#sysfolder}\EllieBot.exe"; IconFilename: "{app}\{#sysfolder}\nadeko_icon.ico"
+Name: "{app}\creds"; Filename: "{app}\{#sysfolder}\creds.yml"
+Name: "{app}\data"; Filename: "{app}\{#sysfolder}\data"
+
+; desktop shortcut
+Name: "{commondesktop}\{#SetupSetting("AppName")}"; Filename: "{app}\EllieBot";
+
+[Code]
+function GetFileName(const AFileName: string): string;
+begin
+ Result := ExpandConstant('{app}\{#sysfolder}\' + AFileName);
+end;
+
+procedure CurStepChanged(CurStep: TSetupStep);
+begin
+ if (CurStep = ssPostInstall) then
+ begin
+ FileCopy(GetFileName('creds_example.yml'), GetFileName('creds.yml'), True);
+ end;
+end;
\ No newline at end of file
diff --git a/migrate.ps1 b/migrate.ps1
new file mode 100644
index 0000000..a5ff6c4
--- /dev/null
+++ b/migrate.ps1
@@ -0,0 +1,9 @@
+if ($args.Length -eq 0) {
+ Write-Host "Please provide a migration name." -ForegroundColor Red
+}
+else {
+ $migrationName = $args[0]
+ dotnet ef migrations add $migrationName -c SqliteContext -p src/EllieBot/EllieBot.csproj
+ dotnet ef migrations add $migrationName -c PostgreSqlContext -p src/EllieBot/EllieBot.csproj
+ dotnet ef migrations add $migrationName -c MysqlContext -p src/EllieBot/EllieBot.csproj
+}
\ No newline at end of file
diff --git a/remove-migrations.ps1 b/remove-migrations.ps1
new file mode 100644
index 0000000..5445dbb
--- /dev/null
+++ b/remove-migrations.ps1
@@ -0,0 +1,3 @@
+dotnet ef migrations remove -c SqliteContext -f -p src/EllieBot/EllieBot.csproj
+dotnet ef migrations remove -c PostgreSqlContext -f -p src/EllieBot/EllieBot.csproj
+dotnet ef migrations remove -c MysqlContext -f -p src/EllieBot/EllieBot.csproj
\ 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..d5a428b
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs
@@ -0,0 +1,10 @@
+namespace EllieBot.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..0f04c23
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs
@@ -0,0 +1,10 @@
+namespace EllieBot.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..31c3cfd
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.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..6bd6af1
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs
@@ -0,0 +1,22 @@
+using Discord;
+
+namespace EllieBot.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..0dc068e
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/cmdAttribute.cs
@@ -0,0 +1,37 @@
+namespace EllieBot.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..be843ae
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/injectAttribute.cs
@@ -0,0 +1,10 @@
+namespace EllieBot.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..71543e2
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs
@@ -0,0 +1,10 @@
+namespace EllieBot.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..2868b23
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/prioAttribute.cs
@@ -0,0 +1,20 @@
+namespace EllieBot.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..a453303
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/svcAttribute.cs
@@ -0,0 +1,23 @@
+namespace EllieBot.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..b0a3aa3
--- /dev/null
+++ b/src/Ellie.Marmalade/Attributes/user_permAttribute.cs
@@ -0,0 +1,22 @@
+using Discord;
+
+namespace EllieBot.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..4b7bbbb
--- /dev/null
+++ b/src/Ellie.Marmalade/Canary.cs
@@ -0,0 +1,143 @@
+using Discord;
+
+namespace EllieBot.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..4f7207c
--- /dev/null
+++ b/src/Ellie.Marmalade/Context/AnyContext.cs
@@ -0,0 +1,43 @@
+using Discord;
+using EllieBot;
+
+namespace EllieBot.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);
+}
\ 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..d971ee5
--- /dev/null
+++ b/src/Ellie.Marmalade/Context/DmContext.cs
@@ -0,0 +1,11 @@
+using Discord;
+
+namespace EllieBot.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..63ca873
--- /dev/null
+++ b/src/Ellie.Marmalade/Context/GuildContext.cs
@@ -0,0 +1,12 @@
+using Discord;
+
+namespace EllieBot.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..db33025
--- /dev/null
+++ b/src/Ellie.Marmalade/Ellie.Marmalade.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ The EllieBot Devs
+
+
+
+
+
+
+
+
+
+ 9.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/MarmaladeExtensions.cs b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs
new file mode 100644
index 0000000..1047966
--- /dev/null
+++ b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs
@@ -0,0 +1,61 @@
+using Discord;
+
+namespace EllieBot.Marmalade;
+
+public static class MarmaladeExtensions
+{
+ public static Task EmbedAsync(this IMessageChannel ch, EmbedBuilder embed, string msg = "")
+ => ch.SendMessageAsync(msg,
+ embed: embed.Build(),
+ options: new()
+ {
+ RetryMode = RetryMode.Retry502
+ });
+
+ // unlocalized
+ public static Task SendConfirmAsync(this AnyContext ctx, string msg)
+ => ctx.Channel.EmbedAsync(new EmbedBuilder()
+ .WithColor(0, 200, 0)
+ .WithDescription(msg));
+
+ public static Task SendPendingAsync(this AnyContext ctx, string msg)
+ => ctx.Channel.EmbedAsync(new EmbedBuilder()
+ .WithColor(200, 200, 0)
+ .WithDescription(msg));
+
+ public static Task SendErrorAsync(this AnyContext ctx, string msg)
+ => ctx.Channel.EmbedAsync(new EmbedBuilder()
+ .WithColor(200, 0, 0)
+ .WithDescription(msg));
+
+ // localized
+ public static Task ConfirmAsync(this AnyContext ctx)
+ => ctx.Message.AddReactionAsync(new Emoji("✅"));
+
+ public static Task ErrorAsync(this AnyContext ctx)
+ => ctx.Message.AddReactionAsync(new Emoji("❌"));
+
+ public static Task WarningAsync(this AnyContext ctx)
+ => ctx.Message.AddReactionAsync(new Emoji("⚠️"));
+
+ public static Task WaitAsync(this AnyContext ctx)
+ => ctx.Message.AddReactionAsync(new Emoji("🤔"));
+
+ public static Task ErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
+ => ctx.SendErrorAsync(ctx.GetText(key, args));
+
+ public static Task PendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
+ => ctx.SendPendingAsync(ctx.GetText(key, args));
+
+ public static Task ConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
+ => ctx.SendConfirmAsync(ctx.GetText(key, args));
+
+ public static Task ReplyErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
+ => ctx.SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
+
+ public static Task ReplyPendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
+ => ctx.SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
+
+ public static Task ReplyConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
+ => ctx.SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
+}
\ No newline at end of file
diff --git a/src/Ellie.Marmalade/IEmbedBuilder.cs b/src/Ellie.Marmalade/IEmbedBuilder.cs
new file mode 100644
index 0000000..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..e4758d6
--- /dev/null
+++ b/src/Ellie.Marmalade/ParamParser/ParamParser.cs
@@ -0,0 +1,16 @@
+namespace EllieBot.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..24c115b
--- /dev/null
+++ b/src/Ellie.Marmalade/ParamParser/ParseResult.cs
@@ -0,0 +1,48 @@
+namespace EllieBot.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..056d028
--- /dev/null
+++ b/src/Ellie.Marmalade/Strings/CommandStrings.cs
@@ -0,0 +1,24 @@
+using YamlDotNet.Serialization;
+
+namespace EllieBot.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..f76cf82
--- /dev/null
+++ b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs
@@ -0,0 +1,15 @@
+using System.Globalization;
+
+namespace EllieBot.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..2845f94
--- /dev/null
+++ b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs
@@ -0,0 +1,28 @@
+namespace EllieBot.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..3b02ca6
--- /dev/null
+++ b/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs
@@ -0,0 +1,40 @@
+namespace EllieBot.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..c4f9f26
--- /dev/null
+++ b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs
@@ -0,0 +1,79 @@
+using System.Globalization;
+using Serilog;
+
+namespace EllieBot.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..e9b2493
--- /dev/null
+++ b/src/Ellie.Marmalade/Strings/StringsLoader.cs
@@ -0,0 +1,137 @@
+using System.Diagnostics.CodeAnalysis;
+using Serilog;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.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/Ellie.Marmalade/pack-and-push.ps1 b/src/Ellie.Marmalade/pack-and-push.ps1
new file mode 100644
index 0000000..5f5a2eb
--- /dev/null
+++ b/src/Ellie.Marmalade/pack-and-push.ps1
@@ -0,0 +1,2 @@
+dotnet pack -o bin/Release/packed
+dotnet nuget push bin/Release/packed/ --source emotionlab
\ 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..b4f964d
--- /dev/null
+++ b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.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..780afc4
--- /dev/null
+++ b/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs
@@ -0,0 +1,456 @@
+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)
+ {
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(shardId, _shardStatuses.Length);
+
+ 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..4b5f8cb
--- /dev/null
+++ b/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs
@@ -0,0 +1,258 @@
+// 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.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)]
+ internal 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)]
+ internal 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)]
+ internal 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