Upped version to 5.0.8

This commit is contained in:
Toastie (DCS Team) 2024-06-19 15:27:11 +12:00
commit 766e3d0ddd
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
977 changed files with 192319 additions and 485 deletions

13
.dockerignore Normal file
View file

@ -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/*

View file

@ -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"

View file

@ -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}

6
NuGet.Config Normal file
View file

@ -0,0 +1,6 @@
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="toastielab.dev" value="https://toastielab.dev/api/packages/ellie/nuget/index.json" protocolVersion="3" />
</packageSources>
</configuration>

View file

@ -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.

View file

@ -1,3 +1,9 @@
# List of things to do
- Finish the full system rewrite
- ~~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

28
docker-entrypoint.sh Normal file
View file

@ -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 "$@"

74
exe_builder.iss Normal file
View file

@ -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;

9
migrate.ps1 Normal file
View file

@ -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
}

3
remove-migrations.ps1 Normal file
View file

@ -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

View file

@ -0,0 +1,10 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Overridden to implement custom checks which commands have to pass in order to be executed.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
public abstract class FilterAttribute : Attribute
{
public abstract ValueTask<bool> CheckAsync(AnyContext ctx);
}

View file

@ -0,0 +1,10 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Used as a marker class for bot_perm and user_perm Attributes
/// Has no functionality.
/// </summary>
public abstract class MarmaladePermAttribute : Attribute
{
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Marmalade;
[AttributeUsage(AttributeTargets.Method)]
public sealed class bot_owner_onlyAttribute : MarmaladePermAttribute
{
}

View file

@ -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;
}
}

View file

@ -0,0 +1,37 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Marks a method as a snek command
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class cmdAttribute : Attribute
{
/// <summary>
/// Command description. Avoid using, as cmds.yml is preferred
/// </summary>
public string? desc { get; set; }
/// <summary>
/// Command args examples. Avoid using, as cmds.yml is preferred
/// </summary>
public string[]? args { get; set; }
/// <summary>
/// Command aliases
/// </summary>
public string[] Aliases { get; }
public cmdAttribute()
{
desc = null;
args = null;
Aliases = Array.Empty<string>();
}
public cmdAttribute(params string[] aliases)
{
Aliases = aliases;
desc = null;
args = null;
}
}

View file

@ -0,0 +1,10 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Marks services in command arguments for injection.
/// The injected services must come after the context and before any input parameters.
/// </summary>
public class injectAttribute : Attribute
{
}

View file

@ -0,0 +1,10 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Marks the parameter to take
/// </summary>
[AttributeUsage(AttributeTargets.Parameter)]
public class leftoverAttribute : Attribute
{
}

View file

@ -0,0 +1,20 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Sets the priority of a command in case there are multiple commands with the same name but different parameters.
/// Higher value means higher priority.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class prioAttribute : Attribute
{
public int Priority { get; }
/// <summary>
/// Snek command priority
/// </summary>
/// <param name="priority">Priority value. The higher the value, the higher the priority</param>
public prioAttribute(int priority)
{
Priority = priority;
}
}

View file

@ -0,0 +1,23 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Marks the class as a service which can be used within the same Medusa
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class svcAttribute : Attribute
{
public Lifetime Lifetime { get; }
public svcAttribute(Lifetime lifetime)
{
Lifetime = lifetime;
}
}
/// <summary>
/// Lifetime for <see cref="svcAttribute"/>
/// </summary>
public enum Lifetime
{
Singleton,
Transient
}

View file

@ -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;
}
}

View file

@ -0,0 +1,143 @@
using Discord;
namespace EllieBot.Marmalade;
/// <summary>
/// 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.
/// </summary>
public abstract class Canary : IAsyncDisposable
{
/// <summary>
/// Name of the canary. Defaults to the lowercase class name
/// </summary>
public virtual string Name
=> GetType().Name.ToLowerInvariant();
/// <summary>
/// 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`
/// </summary>
public virtual string Prefix
=> string.Empty;
/// <summary>
/// Executed once this canary has been instantiated and before any command is executed.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing completion</returns>
public virtual ValueTask InitializeAsync()
=> default;
/// <summary>
/// Override to cleanup any resources or references which might hold this canary in memory
/// </summary>
/// <returns></returns>
public virtual ValueTask DisposeAsync()
=> default;
/// <summary>
/// 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.
/// <para>Execution order:</para>
/// <para>
/// *<see cref="ExecOnMessageAsync"/>* →
/// <see cref="ExecInputTransformAsync"/> →
/// <see cref="ExecPreCommandAsync"/> →
/// <see cref="ExecPostCommandAsync"/> OR <see cref="ExecOnNoCommandAsync"/>
/// </para>
/// </summary>
/// <param name="guild">Guild in which the message was sent</param>
/// <param name="msg">Message received by the bot</param>
/// <returns>A <see cref="ValueTask"/> representing whether the message should be ignored and not processed further</returns>
public virtual ValueTask<bool> ExecOnMessageAsync(IGuild? guild, IUserMessage msg)
=> default;
/// <summary>
/// Override this method to modify input before the bot searches for any commands matching the input
/// Executed after <see cref="ExecOnMessageAsync"/>
/// This is useful if you want to reinterpret the message under some conditions
/// <para>Execution order:</para>
/// <para>
/// <see cref="ExecOnMessageAsync"/> →
/// *<see cref="ExecInputTransformAsync"/>* →
/// <see cref="ExecPreCommandAsync"/> →
/// <see cref="ExecPostCommandAsync"/> OR <see cref="ExecOnNoCommandAsync"/>
/// </para>
/// </summary>
/// <param name="guild">Guild in which the message was sent</param>
/// <param name="channel">Channel in which the message was sent</param>
/// <param name="user">User who sent the message</param>
/// <param name="input">Content of the message</param>
/// <returns>A <see cref="ValueTask"/> representing new, potentially modified content</returns>
public virtual ValueTask<string?> ExecInputTransformAsync(
IGuild? guild,
IMessageChannel channel,
IUser user,
string input
)
=> default;
/// <summary>
/// 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.
/// <para>Execution order:</para>
/// <para>
/// <see cref="ExecOnMessageAsync"/> →
/// <see cref="ExecInputTransformAsync"/> →
/// *<see cref="ExecPreCommandAsync"/>* →
/// <see cref="ExecPostCommandAsync"/> OR <see cref="ExecOnNoCommandAsync"/>
/// </para>
/// </summary>
/// <param name="context">Command context</param>
/// <param name="moduleName">Name of the canary or module from which the command originates</param>
/// <param name="commandName">Name of the command which is about to be executed</param>
/// <returns>A <see cref="ValueTask"/> representing whether the execution should be blocked</returns>
public virtual ValueTask<bool> ExecPreCommandAsync(
AnyContext context,
string moduleName,
string commandName
)
=> default;
/// <summary>
/// This method is called after the command was succesfully executed.
/// If this method was called, then <see cref="ExecOnNoCommandAsync"/> will not be executed
/// <para>Execution order:</para>
/// <para>
/// <see cref="ExecOnMessageAsync"/> →
/// <see cref="ExecInputTransformAsync"/> →
/// <see cref="ExecPreCommandAsync"/> →
/// *<see cref="ExecPostCommandAsync"/>* OR <see cref="ExecOnNoCommandAsync"/>
/// </para>
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing completion</returns>
public virtual ValueTask ExecPostCommandAsync(AnyContext ctx, string moduleName, string commandName)
=> default;
/// <summary>
/// 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 <see cref="ExecPostCommandAsync"/> will not be executed
/// <para>Execution order:</para>
/// <para>
/// <see cref="ExecOnMessageAsync"/> →
/// <see cref="ExecInputTransformAsync"/> →
/// <see cref="ExecPreCommandAsync"/> →
/// <see cref="ExecPostCommandAsync"/> OR *<see cref="ExecOnNoCommandAsync"/>*
/// </para>
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing completion</returns>
public virtual ValueTask ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg)
=> default;
}
public readonly struct ExecResponse
{
}

View file

@ -0,0 +1,43 @@
using Discord;
using EllieBot;
namespace EllieBot.Marmalade;
/// <summary>
/// Commands which take this class as a first parameter can be executed in both DMs and Servers
/// </summary>
public abstract class AnyContext
{
/// <summary>
/// Channel from the which the command is invoked
/// </summary>
public abstract IMessageChannel Channel { get; }
/// <summary>
/// Message which triggered the command
/// </summary>
public abstract IUserMessage Message { get; }
/// <summary>
/// The user who invoked the command
/// </summary>
public abstract IUser User { get; }
/// <summary>
/// Bot user
/// </summary>
public abstract ISelfUser Bot { get; }
/// <summary>
/// Provides access to strings used by this marmalade
/// </summary>
public abstract IMarmaladeStrings Strings { get; }
/// <summary>
/// Gets a formatted localized string using a key and arguments which should be formatted in
/// </summary>
/// <param name="key">The key of the string as specified in localization files</param>
/// <param name="args">Arguments (if any) to format in</param>
/// <returns>A formatted localized string</returns>
public abstract string GetText(string key, object[]? args = null);
}

View file

@ -0,0 +1,11 @@
using Discord;
namespace EllieBot.Marmalade;
/// <summary>
/// Commands which take this type as the first parameter can only be executed in DMs
/// </summary>
public abstract class DmContext : AnyContext
{
public abstract override IDMChannel Channel { get; }
}

View file

@ -0,0 +1,12 @@
using Discord;
namespace EllieBot.Marmalade;
/// <summary>
/// Commands which take this type as a first parameter can only be executed in a server
/// </summary>
public abstract class GuildContext : AnyContext
{
public abstract override ITextChannel Channel { get; }
public abstract IGuild Guild { get; }
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Authors>The EllieBot Devs</Authors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Discord.Net.Core" Version="3.204.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="YamlDotNet" Version="15.1.4" />
</ItemGroup>
<PropertyGroup Condition=" '$(Version)' == '' ">
<Version>9.0.0</Version>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,8 @@
namespace EllieBot;
public enum EmbedColor
{
Ok,
Pending,
Error
}

View file

@ -0,0 +1,61 @@
using Discord;
namespace EllieBot.Marmalade;
public static class MarmaladeExtensions
{
public static Task<IUserMessage> EmbedAsync(this IMessageChannel ch, EmbedBuilder embed, string msg = "")
=> ch.SendMessageAsync(msg,
embed: embed.Build(),
options: new()
{
RetryMode = RetryMode.Retry502
});
// unlocalized
public static Task<IUserMessage> SendConfirmAsync(this AnyContext ctx, string msg)
=> ctx.Channel.EmbedAsync(new EmbedBuilder()
.WithColor(0, 200, 0)
.WithDescription(msg));
public static Task<IUserMessage> SendPendingAsync(this AnyContext ctx, string msg)
=> ctx.Channel.EmbedAsync(new EmbedBuilder()
.WithColor(200, 200, 0)
.WithDescription(msg));
public static Task<IUserMessage> 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<IUserMessage> ErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendErrorAsync(ctx.GetText(key, args));
public static Task<IUserMessage> PendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendPendingAsync(ctx.GetText(key, args));
public static Task<IUserMessage> ConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendConfirmAsync(ctx.GetText(key, args));
public static Task<IUserMessage> ReplyErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
public static Task<IUserMessage> ReplyPendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
public static Task<IUserMessage> ReplyConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
}

View file

@ -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);
}

View file

@ -0,0 +1,16 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Overridden to implement parsers for custom types
/// </summary>
/// <typeparam name="T">Type into which to parse the input</typeparam>
public abstract class ParamParser<T>
{
/// <summary>
/// Overridden to implement parsing logic
/// </summary>
/// <param name="ctx">Context</param>
/// <param name="input">Input to parse</param>
/// <returns>A <see cref="ParseResult{T}"/> with successful or failed status</returns>
public abstract ValueTask<ParseResult<T>> TryParseAsync(AnyContext ctx, string input);
}

View file

@ -0,0 +1,48 @@
namespace EllieBot.Marmalade;
public readonly struct ParseResult<T>
{
/// <summary>
/// Whether the parsing was successful
/// </summary>
public bool IsSuccess { get; private init; }
/// <summary>
/// Parsed value. It should only have value if <see cref="IsSuccess"/> is set to true
/// </summary>
public T? Data { get; private init; }
/// <summary>
/// Instantiate a **successful** parse result
/// </summary>
/// <param name="data">Parsed value</param>
public ParseResult(T data)
{
Data = data;
IsSuccess = true;
}
/// <summary>
/// Create a new <see cref="ParseResult{T}"/> with IsSuccess = false
/// </summary>
/// <returns>A new <see cref="ParseResult{T}"/></returns>
public static ParseResult<T> Fail()
=> new ParseResult<T>
{
IsSuccess = false,
Data = default,
};
/// <summary>
/// Create a new <see cref="ParseResult{T}"/> with IsSuccess = true
/// </summary>
/// <param name="obj">Value of the parsed object</param>
/// <returns>A new <see cref="ParseResult{T}"/></returns>
public static ParseResult<T> Success(T obj)
=> new ParseResult<T>
{
IsSuccess = true,
Data = obj,
};
}

View file

@ -0,0 +1 @@
This is the library which is the base of any marmalade.

View file

@ -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;
}
}

View file

@ -0,0 +1,15 @@
using System.Globalization;
namespace EllieBot.Marmalade;
/// <summary>
/// Defines methods to retrieve and reload marmalade strings
/// </summary>
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);
}

View file

@ -0,0 +1,28 @@
namespace EllieBot.Marmalade;
/// <summary>
/// Implemented by classes which provide localized strings in their own ways
/// </summary>
public interface IMarmaladeStringsProvider
{
/// <summary>
/// Gets localized string
/// </summary>
/// <param name="localeName">Language name</param>
/// <param name="key">String key</param>
/// <returns>Localized string</returns>
string? GetText(string localeName, string key);
/// <summary>
/// Reloads string cache
/// </summary>
void Reload();
// /// <summary>
// /// Gets command arg examples and description
// /// </summary>
// /// <param name="localeName">Language name</param>
// /// <param name="commandName">Command name</param>
// CommandStrings GetCommandStrings(string localeName, string commandName);
CommandStrings? GetCommandStrings(string localeName, string commandName);
}

View file

@ -0,0 +1,40 @@
namespace EllieBot.Marmalade;
public class LocalMarmaladeStringsProvider : IMarmaladeStringsProvider
{
private readonly StringsLoader _source;
private IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> _responseStrings;
private IReadOnlyDictionary<string, IReadOnlyDictionary<string, CommandStrings>> _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;
}
}

View file

@ -0,0 +1,79 @@
using System.Globalization;
using Serilog;
namespace EllieBot.Marmalade;
public class MarmaladeStrings : IMarmaladeStrings
{
/// <summary>
/// Used as failsafe in case response key doesn't exist in the selected or default language.
/// </summary>
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();
}

View file

@ -0,0 +1,137 @@
using System.Diagnostics.CodeAnalysis;
using Serilog;
using YamlDotNet.Serialization;
namespace EllieBot.Marmalade;
/// <summary>
/// Loads strings from the shortcut or localizable path
/// </summary>
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<string, IReadOnlyDictionary<string, CommandStrings>> GetCommandStrings()
{
var outputDict = new Dictionary<string, IReadOnlyDictionary<string, CommandStrings>>();
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<string, CommandStrings>? strings,
out string? localeName)
{
try
{
var text = File.ReadAllText(file);
strings = _deserializer.Deserialize<Dictionary<string, CommandStrings>?>(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<string, IReadOnlyDictionary<string, string>> GetResponseStrings()
{
var outputDict = new Dictionary<string, IReadOnlyDictionary<string, string>>();
// 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<string, string>? strings,
out string? localeName)
{
try
{
strings = _deserializer.Deserialize<Dictionary<string, string>?>(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);
}

View file

@ -0,0 +1,2 @@
dotnet pack -o bin/Release/packed
dotnet nuget push bin/Release/packed/ --source emotionlab

View file

@ -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<CoordinatorRunner>();
services.AddSingleton<IHostedService, CoordinatorRunner>(
serviceProvider => serviceProvider.GetRequiredService<CoordinatorRunner>());
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<CoordinatorService>();
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");
});
});
}
}
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\coordinator.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.62.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="YamlDotNet" Version="15.1.4" />
</ItemGroup>
</Project>

View file

@ -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
}
}
}

View file

@ -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<CoordStartup>();
});
LogSetup.SetupLogger("coord");
Log.Information("Starting coordinator... Pid: {ProcessId}", Environment.ProcessId);
CreateHostBuilder(args).Build().Run();

View file

@ -0,0 +1,13 @@
{
"profiles": {
"EllieBot.Coordinator": {
"commandName": "Project",
"dotnetRunMessages": "true",
"launchBrowser": false,
"applicationUrl": "http://localhost:3442;https://localhost:3443",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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<Config>(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<CoordState>(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<ShardStatus> GetAllStatuses()
{
lock (locker)
{
var toReturn = new List<ShardStatus>(_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<Config>(text);
SaveConfig(in config);
ReloadConfig();
}
}
}

View file

@ -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<HeartbeatReply> 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<ReshardReply> Reshard(ReshardRequest request, ServerCallContext context)
{
_runner.SetShardCount(request.Shards);
return Task.FromResult(new ReshardReply());
}
public override Task<RestartShardReply> RestartShard(RestartShardRequest request, ServerCallContext context)
{
_runner.RestartShard(request.ShardId, request.Queue);
return Task.FromResult(new RestartShardReply());
}
public override Task<ReloadReply> Reload(ReloadRequest request, ServerCallContext context)
{
_runner.ReloadConfig();
return Task.FromResult(new ReloadReply());
}
public override Task<GetStatusReply> GetStatus(GetStatusRequest request, ServerCallContext context)
{
var status = _runner.GetShardStatus(request.ShardId);
return Task.FromResult(StatusToStatusReply(status));
}
public override Task<GetAllStatusesReply> 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<RestartAllReply> RestartAllShards(RestartAllRequest request, ServerCallContext context)
{
_runner.RestartAll(request.Nuke);
return Task.FromResult(new RestartAllReply());
}
public override async Task<DieReply> 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<SetConfigTextReply> 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<SetConfigTextReply>(new(new()
{
Success = success,
Error = error
}));
}
public override Task<GetConfigTextReply> GetConfigText(GetConfigTextRequest request, ServerCallContext context)
{
var text = _runner.GetConfigText();
return Task.FromResult(new GetConfigTextReply()
{
ConfigYml = text,
});
}
}
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace EllieBot.Coordinator
{
public class CoordState
{
public List<JsonStatusObject> StatusObjects { get; init; }
}
}

View file

@ -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; }
}
}

View file

@ -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
);
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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

View file

@ -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 = $$"""
// <AutoGenerated/>
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 = $$"""
// <AutoGenerated/>
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 = $$"""
// <AutoGenerated/>
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}
{{
/// <summary>
/// Creates a copy of {classSymbol.Name} with NO circular reference checking. This method should be used if performance matters.
///
/// <exception cref=""StackOverflowException"">Will occur on any object that has circular references in the hierarchy.</exception>
/// </summary>
public {classSymbol.Name} Clone()
{{
return new {classSymbol.Name}
{{
{string.Join(",\n", fieldAssignmentsCodeFast)}
}};
}}
/// <summary>
/// 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.
/// </summary>
/// <param name=""referenceChain"">Should only be provided if specific objects should not be cloned but passed by reference instead.</param>
public {classSymbol.Name} CloneSafe(Stack<object> referenceChain = null)
{{
if(referenceChain?.Contains(this) == true)
return this;
referenceChain ??= new Stack<object>();
referenceChain.Push(this);
var result = new {classSymbol.Name}
{{
{string.Join($",\n", fieldAssignmentsCodeSafe)}
}};
referenceChain.Pop();
return result;
}}
}}
}}";
}
private IEnumerable<(string line, bool isCloneable)> GenerateFieldAssignmentsCode(INamedTypeSymbol classSymbol, bool isExplicit )
{
var fieldNames = GetCloneableProperties(classSymbol, isExplicit);
var fieldAssignments = fieldNames.Select(field => IsFieldCloneable(field, classSymbol))
.OrderBy(x => x.isCloneable)
.Select(x => (GenerateAssignmentCode(x.item.Name, x.isCloneable), x.isCloneable));
return fieldAssignments;
}
private string GenerateAssignmentCode(string name, bool isCloneable)
{
if (isCloneable)
{
return $@" {name} = this.{name}?.Clone";
}
return $@" {name} = this.{name}";
}
private (IPropertySymbol item, bool isCloneable) IsFieldCloneable(IPropertySymbol x, INamedTypeSymbol classSymbol)
{
if (SymbolEqualityComparer.Default.Equals(x.Type, classSymbol))
{
return (x, false);
}
if (!x.Type.TryGetAttribute(_cloneableAttribute!, out var attributes))
{
return (x, false);
}
var preventDeepCopy = (bool?)attributes.Single().NamedArguments.FirstOrDefault(e => e.Key.Equals(PREVENT_DEEP_COPY_KEY_STRING)).Value.Value ?? false;
return (item: x, !preventDeepCopy);
}
private string GetAccessModifier(INamedTypeSymbol classSymbol)
=> classSymbol.DeclaredAccessibility.ToString().ToLowerInvariant();
private IEnumerable<IPropertySymbol> GetCloneableProperties(ITypeSymbol classSymbol, bool isExplicit)
{
var targetSymbolMembers = classSymbol.GetMembers().OfType<IPropertySymbol>()
.Where(x => x.SetMethod is not null &&
x.CanBeReferencedByName);
if (isExplicit)
{
return targetSymbolMembers.Where(x => x.HasAttribute(_cloneAttribute!));
}
else
{
return targetSymbolMembers.Where(x => !x.HasAttribute(_ignoreCloneAttribute!));
}
}
private static IEnumerable<INamedTypeSymbol> GetClassSymbols(Compilation compilation, SyntaxReceiver receiver)
=> receiver.CandidateClasses.Select(clazz => GetClassSymbol(compilation, clazz));
private static INamedTypeSymbol GetClassSymbol(Compilation compilation, ClassDeclarationSyntax clazz)
{
var model = compilation.GetSemanticModel(clazz.SyntaxTree);
var classSymbol = model.GetDeclaredSymbol(clazz)!;
return classSymbol;
}
private static void InjectCloneableAttributes(GeneratorExecutionContext context)
{
context.AddSource(CLONEABLE_ATTRIBUTE_STRING, SourceText.From(CLONEABLE_ATTRIBUTE_TEXT, Encoding.UTF8));
context.AddSource(CLONE_ATTRIBUTE_STRING, SourceText.From(CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8));
context.AddSource(IGNORE_CLONE_ATTRIBUTE_STRING, SourceText.From(IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8));
}
}
}

View file

@ -0,0 +1,23 @@
// Code temporarily yeeted from
// https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs
// because of NRT issue
using Microsoft.CodeAnalysis;
namespace Cloneable
{
internal static class SymbolExtensions
{
public static bool TryGetAttribute(this ISymbol symbol, INamedTypeSymbol attributeType,
out IEnumerable<AttributeData> attributes)
{
attributes = symbol.GetAttributes()
.Where(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attributeType));
return attributes.Any();
}
public static bool HasAttribute(this ISymbol symbol, INamedTypeSymbol attributeType)
=> symbol.GetAttributes()
.Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attributeType));
}
}

View file

@ -0,0 +1,27 @@
// Code temporarily yeeted from
// https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs
// because of NRT issue
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Cloneable
{
internal class SyntaxReceiver : ISyntaxReceiver
{
public IList<ClassDeclarationSyntax> CandidateClasses { get; } = new List<ClassDeclarationSyntax>();
/// <summary>
/// Called for every syntax node in the compilation, we can inspect the nodes and save any information useful for generation
/// </summary>
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
// any field with at least one attribute is a candidate for being cloneable
if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax &&
classDeclarationSyntax.AttributeLists.Count > 0)
{
CandidateClasses.Add(classDeclarationSyntax);
}
}
}
}

View file

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<IncludeBuildOutput>false</IncludeBuildOutput>
<IsRoslynComponent>true</IsRoslynComponent>
<ImplicitUsings>true</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" PrivateAssets="all" GeneratePathProperty="true" />
</ItemGroup>
<PropertyGroup>
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
</PropertyGroup>
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
</Project>

View file

@ -0,0 +1,140 @@
#nullable enable
using System.CodeDom.Compiler;
using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Newtonsoft.Json;
namespace EllieBot.Generators
{
internal readonly struct TranslationPair
{
public string Name { get; }
public string Value { get; }
public TranslationPair(string name, string value)
{
Name = name;
Value = value;
}
}
[Generator]
public class LocalizedStringsGenerator : ISourceGenerator
{
// private const string LOC_STR_SOURCE = @"namespace EllieBot
// {
// public readonly struct LocStr
// {
// public readonly string Key;
// public readonly object[] Params;
//
// public LocStr(string key, params object[] data)
// {
// Key = key;
// Params = data;
// }
// }
// }";
public void Initialize(GeneratorInitializationContext context)
{
}
public void Execute(GeneratorExecutionContext context)
{
var file = context.AdditionalFiles.First(x => x.Path.EndsWith("responses.en-US.json"));
var fields = GetFields(file.GetText()?.ToString());
using (var stringWriter = new StringWriter())
using (var sw = new IndentedTextWriter(stringWriter))
{
sw.WriteLine("#pragma warning disable CS8981");
sw.WriteLine("namespace EllieBot;");
sw.WriteLine();
sw.WriteLine("public static class strs");
sw.WriteLine("{");
sw.Indent++;
var typedParamStrings = new List<string>(10);
foreach (var field in fields)
{
var matches = Regex.Matches(field.Value, @"{(?<num>\d)[}:]");
var max = 0;
foreach (Match match in matches)
{
max = Math.Max(max, int.Parse(match.Groups["num"].Value) + 1);
}
typedParamStrings.Clear();
var typeParams = new string[max];
var passedParamString = string.Empty;
for (var i = 0; i < max; i++)
{
typedParamStrings.Add($"in T{i} p{i}");
passedParamString += $", p{i}";
typeParams[i] = $"T{i}";
}
var sig = string.Empty;
var typeParamStr = string.Empty;
if (max > 0)
{
sig = $"({string.Join(", ", typedParamStrings)})";
typeParamStr = $"<{string.Join(", ", typeParams)}>";
}
sw.WriteLine("public static LocStr {0}{1}{2} => new LocStr(\"{3}\"{4});",
field.Name,
typeParamStr,
sig,
field.Name,
passedParamString);
}
sw.Indent--;
sw.WriteLine("}");
sw.Flush();
context.AddSource("strs.g.cs", stringWriter.ToString());
}
// context.AddSource("LocStr.g.cs", LOC_STR_SOURCE);
}
private List<TranslationPair> GetFields(string? dataText)
{
if (string.IsNullOrWhiteSpace(dataText))
return new();
Dictionary<string, string> data;
try
{
var output = JsonConvert.DeserializeObject<Dictionary<string, string>>(dataText!);
if (output is null)
return new();
data = output;
}
catch
{
Debug.WriteLine("Failed parsing responses file.");
return new();
}
var list = new List<TranslationPair>();
foreach (var entry in data)
{
list.Add(new(
entry.Key,
entry.Value
));
}
return list;
}
}
}

View file

@ -0,0 +1,24 @@
## Generators
Project which contains source generators required for EllieBot project
---
### 1) Localized Strings Generator
-- Why --
Type safe response strings access, and enforces correct usage of response strings.
-- How it works --
Creates a file "strs.cs" containing a class called "strs" in "EllieBot" namespace.
Loads "data/strings/responses.en-US.json" and creates a property or a function for each key in the responses json file based on whether the value has string format placeholders or not.
- If a value has no placeholders, it creates a property in the strs class which returns an instance of a LocStr struct containing only the key and no replacement parameters
- If a value has placeholders, it creates a function with the same number of arguments as the number of placeholders, and passes those arguments to the LocStr instance
-- How to use --
1. Add a new key to responses.en-US.json "greet_me": "Hello, {0}"
2. You now have access to a function strs.greet_me(obj p1)
3. Using "GetText(strs.greet_me("Me"))" will return "Hello, Me"

View file

@ -0,0 +1,131 @@
using NUnit.Framework;
using System.Globalization;
using System.Linq;
using System.Reflection;
using Discord.Commands;
using EllieBot.Common;
using EllieBot.Common.Attributes;
using EllieBot.Services;
namespace EllieBot.Tests
{
public class CommandStringsTests
{
private const string responsesPath = "../../../../EllieBot/data/strings/responses";
private const string commandsPath = "../../../../EllieBot/data/strings/commands";
private const string aliasesPath = "../../../../EllieBot/data/aliases.yml";
[Test]
public void AllCommandNamesHaveStrings()
{
var stringsSource = new LocalFileStringsSource(
responsesPath,
commandsPath);
var strings = new MemoryBotStringsProvider(stringsSource);
var culture = new CultureInfo("en-US");
var isSuccess = true;
foreach (var (methodName, _) in CommandNameLoadHelper.LoadAliases(aliasesPath))
{
var cmdStrings = strings.GetCommandStrings(culture.Name, methodName);
if (cmdStrings is null)
{
isSuccess = false;
TestContext.Out.WriteLine($"{methodName} doesn't exist in commands.en-US.yml");
}
}
Assert.IsTrue(isSuccess);
}
private static string[] GetCommandMethodNames()
=> typeof(Bot).Assembly
.GetExportedTypes()
.Where(type => type.IsClass && !type.IsAbstract)
.Where(type => typeof(EllieModule).IsAssignableFrom(type) // if its a top level module
|| !(type.GetCustomAttribute<GroupAttribute>(true) is null)) // or a submodule
.SelectMany(x => x.GetMethods()
.Where(mi => mi.CustomAttributes
.Any(ca => ca.AttributeType == typeof(CmdAttribute))))
.Select(x => x.Name.ToLowerInvariant())
.ToArray();
[Test]
public void AllCommandMethodsHaveNames()
{
var allAliases = CommandNameLoadHelper.LoadAliases(
aliasesPath);
var methodNames = GetCommandMethodNames();
var isSuccess = true;
foreach (var methodName in methodNames)
{
if (!allAliases.TryGetValue(methodName, out _))
{
TestContext.Error.WriteLine($"{methodName} is missing an alias.");
isSuccess = false;
}
}
Assert.IsTrue(isSuccess);
}
[Test]
public void NoObsoleteAliases()
{
var allAliases = CommandNameLoadHelper.LoadAliases(aliasesPath);
var methodNames = GetCommandMethodNames()
.ToHashSet();
var isSuccess = true;
foreach (var item in allAliases)
{
var methodName = item.Key;
if (!methodNames.Contains(methodName))
{
TestContext.WriteLine($"'{methodName}' from aliases.yml doesn't have a matching command method.");
isSuccess = false;
}
}
if (isSuccess)
Assert.Pass();
else
Assert.Warn("There are some unused entries in data/aliases.yml");
}
[Test]
public void NoObsoleteCommandStrings()
{
var stringsSource = new LocalFileStringsSource(responsesPath, commandsPath);
var culture = new CultureInfo("en-US");
var methodNames = GetCommandMethodNames()
.ToHashSet();
var isSuccess = true;
// var allCommandNames = CommandNameLoadHelper.LoadCommandStrings(commandsPath));
foreach (var entry in stringsSource.GetCommandStrings()[culture.Name])
{
var cmdName = entry.Key;
if (!methodNames.Contains(cmdName))
{
TestContext.Out.WriteLine($"'{cmdName}' from commands.en-US.yml doesn't have a matching command method.");
isSuccess = false;
}
}
if (isSuccess)
Assert.IsTrue(isSuccess);
else
Assert.Warn("There are some unused command strings in data/strings/commands.en-US.yml");
}
}
}

View file

@ -0,0 +1,93 @@
using System.Collections.Generic;
using NUnit.Framework;
namespace EllieBot.Tests;
public class ConcurrentHashSetTests
{
private ConcurrentHashSet<(int?, int?)> _set;
[SetUp]
public void SetUp()
{
_set = new();
}
[Test]
public void AddTest()
{
var result = _set.Add((1, 2));
Assert.AreEqual(true, result);
result = _set.Add((1, 2));
Assert.AreEqual(false, result);
}
[Test]
public void TryRemoveTest()
{
_set.Add((1, 2));
var result = _set.TryRemove((1, 2));
Assert.AreEqual(true, result);
result = _set.TryRemove((1, 2));
Assert.AreEqual(false, result);
}
[Test]
public void CountTest()
{
_set.Add((1, 2)); // 1
_set.Add((1, 2)); // 1
_set.Add((2, 2)); // 2
_set.Add((3, 2)); // 3
_set.Add((3, 2)); // 3
Assert.AreEqual(3, _set.Count);
}
[Test]
public void ClearTest()
{
_set.Add((1, 2));
_set.Add((1, 3));
_set.Add((1, 4));
_set.Clear();
Assert.AreEqual(0, _set.Count);
}
[Test]
public void ContainsTest()
{
_set.Add((1, 2));
_set.Add((3, 2));
Assert.AreEqual(true, _set.Contains((1, 2)));
Assert.AreEqual(true, _set.Contains((3, 2)));
Assert.AreEqual(false, _set.Contains((2, 1)));
Assert.AreEqual(false, _set.Contains((2, 3)));
}
[Test]
public void RemoveWhereTest()
{
_set.Add((1, 2));
_set.Add((1, 3));
_set.Add((1, 4));
_set.Add((2, 5));
// remove tuples which have even second item
_set.RemoveWhere(static x => x.Item2 % 2 == 0);
Assert.AreEqual(2, _set.Count);
Assert.AreEqual(true, _set.Contains((1, 3)));
Assert.AreEqual(true, _set.Contains((2, 5)));
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EllieBot\EllieBot.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,76 @@
using System.Linq;
using System.Threading.Tasks;
using Ellie.Common;
using EllieBot.Services;
using NUnit.Framework;
namespace EllieBot.Tests
{
public class GroupGreetTests
{
private GreetGrouper<int> _grouper;
[SetUp]
public void Setup()
=> _grouper = new GreetGrouper<int>();
[Test]
public void CreateTest()
{
var created = _grouper.CreateOrAdd(0, 5);
Assert.True(created);
}
[Test]
public void CreateClearTest()
{
_grouper.CreateOrAdd(0, 5);
_grouper.ClearGroup(0, 5, out var items);
Assert.AreEqual(0, items.Count());
}
[Test]
public void NotCreatedTest()
{
_grouper.CreateOrAdd(0, 5);
var created = _grouper.CreateOrAdd(0, 4);
Assert.False(created);
}
[Test]
public void ClearAddedTest()
{
_grouper.CreateOrAdd(0, 5);
_grouper.CreateOrAdd(0, 4);
_grouper.ClearGroup(0, 5, out var items);
var list = items.ToList();
Assert.AreEqual(1, list.Count, $"Count was {list.Count}");
Assert.AreEqual(4, list[0]);
}
[Test]
public async Task ClearManyTest()
{
_grouper.CreateOrAdd(0, 5);
// add 15 items
await Enumerable.Range(10, 15)
.Select(x => Task.Run(() => _grouper.CreateOrAdd(0, x))).WhenAll();
// get 5 at most
_grouper.ClearGroup(0, 5, out var items);
var list = items.ToList();
Assert.AreEqual(5, list.Count, $"Count was {list.Count}");
// try to get 15, but there should be 10 left
_grouper.ClearGroup(0, 15, out items);
list = items.ToList();
Assert.AreEqual(10, list.Count, $"Count was {list.Count}");
}
}
}

View file

@ -0,0 +1,188 @@
using Ellie.Common;
using EllieBot.Db.Models;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;
namespace EllieBot.Tests
{
public class IndexedCollectionTests
{
[Test]
public void AddTest()
{
var collection = GetCollectionSample(Enumerable.Empty<ShopEntry>());
// Add the items
for (var counter = 0; counter < 10; counter++)
collection.Add(new ShopEntry());
// Evaluate the items are ordered
CheckIndices(collection);
}
[Test]
public void RemoveTest()
{
var collection = GetCollectionSample<ShopEntry>();
collection.Remove(collection[1]);
collection.Remove(collection[1]);
// Evaluate the indices are ordered
CheckIndices(collection);
Assert.AreEqual(8, collection.Count);
}
[Test]
public void RemoveAtTest()
{
var collection = GetCollectionSample<ShopEntry>();
// Remove items 5 and 7
collection.RemoveAt(5);
collection.RemoveAt(6);
// Evaluate if the items got removed
foreach (var item in collection)
Assert.IsFalse(item.Id == 5 || item.Id == 7, $"Item at index {item.Index} was not removed");
CheckIndices(collection);
// RemoveAt out of range
Assert.Throws<ArgumentOutOfRangeException>(() => collection.RemoveAt(999), $"No exception thrown when removing from index 999 in a collection of size {collection.Count}.");
Assert.Throws<ArgumentOutOfRangeException>(() => collection.RemoveAt(-3), $"No exception thrown when removing from negative index -3.");
}
[Test]
public void ClearTest()
{
var collection = GetCollectionSample<ShopEntry>();
collection.Clear();
Assert.IsTrue(collection.Count == 0, "Collection has not been cleared.");
Assert.Throws<ArgumentOutOfRangeException>(() =>
{
_ = collection[0];
}, "Collection has not been cleared.");
}
[Test]
public void CopyToTest()
{
var collection = GetCollectionSample<ShopEntry>();
var fullCopy = new ShopEntry[10];
collection.CopyTo(fullCopy, 0);
// Evaluate copy
for (var index = 0; index < fullCopy.Length; index++)
Assert.AreEqual(index, fullCopy[index].Index);
Assert.Throws<ArgumentException>(() => collection.CopyTo(new ShopEntry[10], 4));
Assert.Throws<ArgumentException>(() => collection.CopyTo(new ShopEntry[6], 0));
}
[Test]
public void IndexOfTest()
{
var collection = GetCollectionSample<ShopEntry>();
Assert.AreEqual(4, collection.IndexOf(collection[4]));
Assert.AreEqual(0, collection.IndexOf(collection[0]));
Assert.AreEqual(7, collection.IndexOf(collection[7]));
Assert.AreEqual(9, collection.IndexOf(collection[9]));
}
[Test]
public void InsertTest()
{
var collection = GetCollectionSample<ShopEntry>();
// Insert items at indices 5 and 7
collection.Insert(5, new ShopEntry() { Id = 555 });
collection.Insert(7, new ShopEntry() { Id = 777 });
Assert.AreEqual(12, collection.Count);
Assert.AreEqual(555, collection[5].Id);
Assert.AreEqual(777, collection[7].Id);
CheckIndices(collection);
// Insert out of range
Assert.Throws<ArgumentOutOfRangeException>(() => collection.Insert(999, new ShopEntry() { Id = 999 }), $"No exception thrown when inserting at index 999 in a collection of size {collection.Count}.");
Assert.Throws<ArgumentOutOfRangeException>(() => collection.Insert(-3, new ShopEntry() { Id = -3 }), $"No exception thrown when inserting at negative index -3.");
}
[Test]
public void ContainsTest()
{
var subCol = new[]
{
new ShopEntry() { Id = 111 },
new ShopEntry() { Id = 222 },
new ShopEntry() { Id = 333 }
};
var collection = GetCollectionSample(
Enumerable.Range(0, 10)
.Select(x => new ShopEntry() { Id = x })
.Concat(subCol)
);
collection.Remove(subCol[1]);
CheckIndices(collection);
Assert.IsTrue(collection.Contains(subCol[0]));
Assert.IsFalse(collection.Contains(subCol[1]));
Assert.IsTrue(collection.Contains(subCol[2]));
}
[Test]
public void EnumeratorTest()
{
var collection = GetCollectionSample<ShopEntry>();
var enumerator = collection.GetEnumerator();
foreach (var item in collection)
{
enumerator.MoveNext();
Assert.AreEqual(item, enumerator.Current);
}
}
[Test]
public void IndexTest()
{
var collection = GetCollectionSample<ShopEntry>();
collection[4] = new ShopEntry() { Id = 444 };
collection[7] = new ShopEntry() { Id = 777 };
CheckIndices(collection);
Assert.AreEqual(444, collection[4].Id);
Assert.AreEqual(777, collection[7].Id);
}
/// <summary>
/// Checks whether all indices of the items are properly ordered.
/// </summary>
/// <typeparam name="T">An indexed, reference type.</typeparam>
/// <param name="collection">The indexed collection to be checked.</param>
private void CheckIndices<T>(IndexedCollection<T> collection) where T : class, IIndexed
{
for (var index = 0; index < collection.Count; index++)
Assert.AreEqual(index, collection[index].Index);
}
/// <summary>
/// Gets an <see cref="IndexedCollection{T}"/> from the specified <paramref name="sample"/> or a collection with 10 shop entries if none is provided.
/// </summary>
/// <typeparam name="T">An indexed, database entity type.</typeparam>
/// <param name="sample">A sample collection to be added as an indexed collection.</param>
/// <returns>An indexed collection of <typeparamref name="T"/>.</returns>
private IndexedCollection<T> GetCollectionSample<T>(IEnumerable<T> sample = default) where T : DbEntity, IIndexed, new()
=> new IndexedCollection<T>(sample ?? Enumerable.Range(0, 10).Select(x => new T() { Id = x }));
}
}

View file

@ -0,0 +1,124 @@
using Ellie.Common;
using NUnit.Framework;
namespace EllieBot.Tests
{
public class KwumTests
{
[Test]
public void TestDefaultHashCode()
{
var num = default(kwum);
Assert.AreEqual(0, num.GetHashCode());
}
[Test]
public void TestEqualGetHashCode()
{
var num1 = new kwum("234");
var num2 = new kwum("234");
Assert.AreEqual(num1.GetHashCode(), num2.GetHashCode());
}
[Test]
public void TestNotEqualGetHashCode()
{
var num1 = new kwum("234");
var num2 = new kwum("235");
Assert.AreNotEqual(num1.GetHashCode(), num2.GetHashCode());
}
[Test]
public void TestLongEqualGetHashCode()
{
var num1 = new kwum("hgbkhdbk");
var num2 = new kwum("hgbkhdbk");
Assert.AreEqual(num1.GetHashCode(), num2.GetHashCode());
}
[Test]
public void TestEqual()
{
var num1 = new kwum("hgbkhd");
var num2 = new kwum("hgbkhd");
Assert.AreEqual(num1, num2);
}
[Test]
public void TestNotEqual()
{
var num1 = new kwum("hgbk5d");
var num2 = new kwum("hgbk4d");
Assert.AreNotEqual(num1, num2);
}
[Test]
public void TestParseValidValue()
{
var validValue = "234e";
Assert.True(kwum.TryParse(validValue, out _));
}
[Test]
public void TestParseInvalidValue()
{
var invalidValue = "1234";
Assert.False(kwum.TryParse(invalidValue, out _));
}
[Test]
public void TestCorrectParseValue()
{
var validValue = "qwerf4bm";
kwum.TryParse(validValue, out var parsedValue);
Assert.AreEqual(parsedValue, new kwum(validValue));
}
[Test]
public void TestToString()
{
var validValue = "46g5yh";
kwum.TryParse(validValue, out var parsedValue);
Assert.AreEqual(validValue, parsedValue.ToString());
}
[Test]
public void TestConversionsToFromInt()
{
var num = new kwum(10);
Assert.AreEqual(10, (int)num);
Assert.AreEqual(num, (kwum)10);
}
[Test]
public void TestConverstionsToString()
{
var num = new kwum(10);
Assert.AreEqual("c", num.ToString());
num = new kwum(123);
Assert.AreEqual("5v", num.ToString());
// leading zeros have no meaning
Assert.AreEqual(new kwum("22225v"), num);
}
[Test]
public void TestMaxValue()
{
var num = new kwum(int.MaxValue - 1);
Assert.AreEqual("3zzzzzy", num.ToString());
num = new kwum(int.MaxValue);
Assert.AreEqual("3zzzzzz", num.ToString());
}
}
}

View file

@ -0,0 +1,84 @@
using Ellie.Econ;
using NUnit.Framework;
namespace EllieBot.Tests;
public class NewDeckTests
{
private RegularDeck _deck;
[SetUp]
public void Setup()
{
_deck = new RegularDeck();
}
[Test]
public void TestCount()
{
Assert.AreEqual(52, _deck.TotalCount);
Assert.AreEqual(52, _deck.CurrentCount);
}
[Test]
public void TestDeckDraw()
{
var card = _deck.Draw();
Assert.IsNotNull(card);
Assert.AreEqual(card.Suit, RegularSuit.Hearts);
Assert.AreEqual(card.Value, RegularValue.Ace);
Assert.AreEqual(_deck.CurrentCount, _deck.TotalCount - 1);
}
[Test]
public void TestDeckSpent()
{
for (var i = 0; i < _deck.TotalCount - 1; ++i)
{
_deck.Draw();
}
var lastCard = _deck.Draw();
Assert.IsNotNull(lastCard);
Assert.AreEqual(new RegularCard(RegularSuit.Spades, RegularValue.King), lastCard);
var noCard = _deck.Draw();
Assert.IsNull(noCard);
}
[Test]
public void TestCardGetName()
{
var ace = _deck.Draw()!;
var two = _deck.Draw()!;
Assert.AreEqual("Ace of Hearts", ace.GetName());
Assert.AreEqual("Two of Hearts", two.GetName());
}
[Test]
public void TestPeek()
{
var ace = _deck.Peek()!;
var tenOfSpades = _deck.Peek(48);
Assert.AreEqual(new RegularCard(RegularSuit.Hearts, RegularValue.Ace), ace);
Assert.AreEqual(new RegularCard(RegularSuit.Spades, RegularValue.Ten), tenOfSpades);
}
[Test]
public void TestMultipleDeck()
{
var quadDeck = new MultipleRegularDeck(4);
var count = quadDeck.TotalCount;
Assert.AreEqual(52 * 4, count);
var card = quadDeck.Peek(54);
Assert.AreEqual(new RegularCard(RegularSuit.Hearts, RegularValue.Three), card);
}
}

View file

@ -0,0 +1,136 @@
using System.Threading.Tasks;
using Ellie.Common;
using NUnit.Framework;
using NUnit.Framework.Internal;
namespace EllieBot.Tests
{
public class PubSubTests
{
[Test]
public async Task Test_EventPubSub_PubSub()
{
TypedKey<int> key = "test_key";
var expected = new Randomizer().Next();
var pubsub = new EventPubSub();
await pubsub.Sub(key, data =>
{
Assert.AreEqual(expected, data);
Assert.Pass();
return default;
});
await pubsub.Pub(key, expected);
Assert.Fail("Event not registered");
}
[Test]
public async Task Test_EventPubSub_MeaninglessUnsub()
{
TypedKey<int> key = "test_key";
var expected = new Randomizer().Next();
var pubsub = new EventPubSub();
await pubsub.Sub(key, data =>
{
Assert.AreEqual(expected, data);
Assert.Pass();
return default;
});
await pubsub.Unsub(key, _ => default);
await pubsub.Pub(key, expected);
Assert.Fail("Event not registered");
}
[Test]
public async Task Test_EventPubSub_MeaninglessUnsubThatLooksTheSame()
{
TypedKey<int> key = "test_key";
var expected = new Randomizer().Next();
var pubsub = new EventPubSub();
await pubsub.Sub(key, data =>
{
Assert.AreEqual(expected, data);
Assert.Pass();
return default;
});
await pubsub.Unsub(key, data =>
{
Assert.AreEqual(expected, data);
Assert.Pass();
return default;
});
await pubsub.Pub(key, expected);
Assert.Fail("Event not registered");
}
[Test]
public async Task Test_EventPubSub_MeaningfullUnsub()
{
TypedKey<int> key = "test_key";
var pubsub = new EventPubSub();
ValueTask Action(int data)
{
Assert.Fail("Event is raised when it shouldn't be");
return default;
}
await pubsub.Sub(key, Action);
await pubsub.Unsub(key, Action);
await pubsub.Pub(key, 0);
Assert.Pass();
}
[Test]
public async Task Test_EventPubSub_ObjectData()
{
TypedKey<byte[]> key = "test_key";
var pubsub = new EventPubSub();
var localData = new byte[1];
ValueTask Action(byte[] data)
{
Assert.AreEqual(localData, data);
Assert.Pass();
return default;
}
await pubsub.Sub(key, Action);
await pubsub.Pub(key, localData);
Assert.Fail("Event not raised");
}
[Test]
public async Task Test_EventPubSub_MultiSubUnsub()
{
TypedKey<object> key = "test_key";
var pubsub = new EventPubSub();
var localData = new object();
int successCounter = 0;
ValueTask Action1(object data)
{
Assert.AreEqual(localData, data);
successCounter += 10;
return default;
}
ValueTask Action2(object data)
{
Assert.AreEqual(localData, data);
successCounter++;
return default;
}
await pubsub.Sub(key, Action1); // + 10 \
await pubsub.Sub(key, Action2); // + 1 - + = 12
await pubsub.Sub(key, Action2); // + 1 /
await pubsub.Unsub(key, Action2); // - 1/
await pubsub.Pub(key, localData);
Assert.AreEqual(successCounter, 11, "Not all events are raised.");
}
}
}

View file

@ -0,0 +1 @@
Project which contains tests. Self explanatory

View file

@ -0,0 +1,23 @@
using System;
using System.Text;
using EllieBot.Common.Yml;
using NUnit.Framework;
namespace EllieBot.Tests
{
public class RandomTests
{
[SetUp]
public void Setup()
=> Console.OutputEncoding = Encoding.UTF8;
[Test]
public void Utf8CodepointsToEmoji()
{
var point = @"0001F338";
var hopefullyEmoji = YamlHelper.UnescapeUnicodeCodePoint(point);
Assert.AreEqual("🌸", hopefullyEmoji, hopefullyEmoji);
}
}
}

View file

@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Ayu.Discord.Gateway
{
public static class CloseCodes
{
private static IReadOnlyDictionary<int, (string, string)> _closeCodes = new ReadOnlyDictionary<int, (string, string)>(
new Dictionary<int, (string, string)>()
{
{ 4000, ("Unknown error", "We're not sure what went wrong. Try reconnecting?")},
{ 4001, ("Unknown opcode", "You sent an invalid Gateway opcode or an invalid payload for an opcode. Don't do that!")},
{ 4002, ("Decode error", "You sent an invalid payload to us. Don't do that!")},
{ 4003, ("Not authenticated", "You sent us a payload prior to identifying.")},
{ 4004, ("Authentication failed", "The account token sent with your identify payload is incorrect.")},
{ 4005, ("Already authenticated", "You sent more than one identify payload. Don't do that!")},
{ 4007, ("Invalid seq", "The sequence sent when resuming the session was invalid. Reconnect and start a new session.")},
{ 4008, ("Rate limited", "Woah nelly! You're sending payloads to us too quickly. Slow it down! You will be disconnected on receiving this.")},
{ 4009, ("Session timed out", "Your session timed out. Reconnect and start a new one.")},
{ 4010, ("Invalid shard", "You sent us an invalid shard when identifying.")},
{ 4011, ("Sharding required", "The session would have handled too many guilds - you are required to shard your connection in order to connect.")},
{ 4012, ("Invalid API version", "You sent an invalid version for the gateway.")},
{ 4013, ("Invalid intent(s)", "You sent an invalid intent for a Gateway Intent. You may have incorrectly calculated the bitwise value.")},
{ 4014, ("Disallowed intent(s)", "You sent a disallowed intent for a Gateway Intent. You may have tried to specify an intent that you have not enabled or are not whitelisted for.")}
});
public static (string Error, string Message) GetErrorCodeMessage(int closeCode)
{
if (_closeCodes.TryGetValue(closeCode, out var data))
return data;
return ("Unknown error", closeCode.ToString());
}
}
}

View file

@ -1,17 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk" ToolsVersion="Current">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>CS8632</NoWarn>
<Version>1.0.2</Version>
<RootNamespace>EllieBot.Voice</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="System.Threading.Channels" Version="6.0.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
</ItemGroup>
</Project>

View file

@ -1,11 +1,11 @@
using System;
using System.Runtime.InteropServices;
namespace Ayu.Discord.Voice
namespace EllieBot.Voice
{
internal static unsafe class LibOpus
{
public const string OPUS = "opus";
public const string OPUS = "data/lib/opus";
[DllImport(OPUS, EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)]
internal static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error);
@ -53,11 +53,11 @@ namespace Ayu.Discord.Voice
_frameDelay = frameDelay;
_frameSizePerChannel = _sampleRate * _frameDelay / 1000;
_encoderPtr = LibOpus.CreateEncoder(sampleRate, channels, (int) OpusApplication.Audio, out var error);
_encoderPtr = LibOpus.CreateEncoder(sampleRate, channels, (int)OpusApplication.Audio, out var error);
if (error != OpusError.OK)
throw new ExternalException(error.ToString());
LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetSignal, (int) OpusSignal.Music);
LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetSignal, (int)OpusSignal.Music);
LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetInbandFEC, 1);
LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetBitrate, bitRate);
LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetPacketLossPerc, 2);

View file

@ -1,11 +1,11 @@
using System;
using System.Runtime.InteropServices;
namespace Ayu.Discord.Voice
namespace EllieBot.Voice
{
internal static unsafe class Sodium
{
private const string SODIUM = "libsodium";
private const string SODIUM = "data/lib/libsodium";
[DllImport(SODIUM, EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)]
private static extern int SecretBoxEasy(byte* output, byte* input, long inputLength, byte* nonce, byte* secret);

View file

@ -1,6 +1,6 @@
using Newtonsoft.Json;
using Newtonsoft.Json;
namespace Ayu.Discord.Voice.Models
namespace EllieBot.Voice.Models
{
public sealed class SelectProtocol
{

View file

@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace Ayu.Discord.Voice.Models
namespace EllieBot.Voice.Models
{
public sealed class VoiceHello
{

View file

@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace Ayu.Discord.Voice.Models
namespace EllieBot.Voice.Models
{
public sealed class VoiceIdentify
{

View file

@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace Ayu.Discord.Voice.Models
namespace EllieBot.Voice.Models
{
public sealed class VoiceReady
{

View file

@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace Ayu.Discord.Voice.Models
namespace EllieBot.Voice.Models
{
public sealed class VoiceResume
{

View file

@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace Ayu.Discord.Voice.Models
namespace EllieBot.Voice.Models
{
public sealed class VoiceSessionDescription
{

View file

@ -1,7 +1,7 @@
using Newtonsoft.Json;
using System;
namespace Ayu.Discord.Voice.Models
namespace EllieBot.Voice.Models
{
public sealed class VoiceSpeaking
{

View file

@ -4,7 +4,7 @@ using System.Buffers;
using System.Threading;
using System.Threading.Tasks;
namespace Ayu.Discord.Voice
namespace EllieBot.Voice
{
public sealed class PoopyBufferImmortalized : ISongBuffer
{
@ -104,7 +104,7 @@ namespace Ayu.Discord.Voice
// writer never writes until the end,
// but leaves a single chunk free
Span<byte> toReturn = _outputArray;
((Span<byte>) _buffer).Slice(ReadPosition, toRead).CopyTo(toReturn);
((Span<byte>)_buffer).Slice(ReadPosition, toRead).CopyTo(toReturn);
ReadPosition += toRead;
length = toRead;
return toReturn;
@ -113,7 +113,7 @@ namespace Ayu.Discord.Voice
{
Span<byte> toReturn = _outputArray;
var toEnd = _buffer.Length - ReadPosition;
var bufferSpan = (Span<byte>) _buffer;
var bufferSpan = (Span<byte>)_buffer;
bufferSpan.Slice(ReadPosition, toEnd).CopyTo(toReturn);
var fromStart = toRead - toEnd;

View file

@ -33,7 +33,7 @@ namespace Ayu.Discord.Gateway
bufferWriter.Advance(result.Count);
if (result.MessageType == WebSocketMessageType.Close)
{
var closeMessage = CloseCodes.GetErrorCodeMessage((int?) _ws.CloseStatus ?? 0).Message;
var closeMessage = CloseCodes.GetErrorCodeMessage((int?)_ws.CloseStatus ?? 0).Message;
error = $"Websocket closed ({_ws.CloseStatus}): {_ws.CloseStatusDescription} {closeMessage}";
break;
}
@ -130,7 +130,7 @@ namespace Ayu.Discord.Gateway
await _ws.CloseAsync(WebSocketCloseStatus.InternalServerError, msg, CancellationToken.None)
.ConfigureAwait(false);
return true;
return true;
}
catch
{

View file

@ -4,7 +4,7 @@ using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace Ayu.Discord.Voice
namespace EllieBot.Voice
{
public interface ISongBuffer : IDisposable
{

View file

@ -1,7 +1,7 @@
using System;
using System.Buffers;
namespace Ayu.Discord.Voice
namespace EllieBot.Voice
{
public sealed class VoiceClient : IDisposable
{
@ -31,11 +31,11 @@ namespace Ayu.Discord.Voice
FrameDelay frameDelay = FrameDelay.Delay20,
BitDepthEnum bitDepthEnum = BitDepthEnum.Float32)
{
this.frameDelay = (int) frameDelay;
this.sampleRate = (int) sampleRate;
this.bitRate = (int) bitRate;
this.channels = (int) channels;
this.bitDepth = (int) bitDepthEnum;
this.frameDelay = (int)frameDelay;
this.sampleRate = (int)sampleRate;
this.bitRate = (int)bitRate;
this.channels = (int)channels;
this.bitDepth = (int)bitDepthEnum;
this.Encoder = new(this.sampleRate, this.channels, this.bitRate, this.frameDelay);
@ -63,7 +63,7 @@ namespace Ayu.Discord.Voice
var secretKey = gw.SecretKey;
if (secretKey.Length == 0)
{
return (int) SendPcmError.SecretKeyUnavailable;
return (int)SendPcmError.SecretKeyUnavailable;
}
// encode using opus
@ -84,7 +84,7 @@ namespace Ayu.Discord.Voice
var secretKey = gw.SecretKey;
if (secretKey is null)
{
return (int) SendPcmError.SecretKeyUnavailable;
return (int)SendPcmError.SecretKeyUnavailable;
}
// form RTP header
@ -105,7 +105,7 @@ namespace Ayu.Discord.Voice
var timestampBytes = BitConverter.GetBytes(gw.Timestamp); // 4
var ssrcBytes = BitConverter.GetBytes(gw.Ssrc); // 4
gw.Timestamp += (uint) FrameSizePerChannel;
gw.Timestamp += (uint)FrameSizePerChannel;
gw.Sequence++;
gw.NonceSequence++;

View file

@ -0,0 +1,375 @@
using EllieBot.Voice.Models;
using Discord.Models.Gateway;
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Ayu.Discord.Gateway;
using Newtonsoft.Json;
namespace EllieBot.Voice
{
public class VoiceGateway
{
private class QueueItem
{
public VoicePayload Payload { get; }
public TaskCompletionSource<bool> Result { get; }
public QueueItem(VoicePayload payload, TaskCompletionSource<bool> result)
{
Payload = payload;
Result = result;
}
}
private readonly ulong _guildId;
private readonly ulong _userId;
private readonly string _sessionId;
private readonly string _token;
private readonly string _endpoint;
private readonly Uri _websocketUrl;
private readonly Channel<QueueItem> _channel;
public TaskCompletionSource<bool> ConnectingFinished { get; }
private readonly Random _rng;
private readonly SocketClient _ws;
private readonly UdpClient _udpClient;
private Timer? _heartbeatTimer;
private bool _receivedAck;
private IPEndPoint? _udpEp;
public uint Ssrc { get; private set; }
public string Ip { get; private set; } = string.Empty;
public int Port { get; private set; } = 0;
public byte[] SecretKey { get; private set; } = Array.Empty<byte>();
public string Mode { get; private set; } = string.Empty;
public ushort Sequence { get; set; }
public uint NonceSequence { get; set; }
public uint Timestamp { get; set; }
public string MyIp { get; private set; } = string.Empty;
public ushort MyPort { get; private set; }
private bool _shouldResume;
private readonly CancellationTokenSource _stopCancellationSource;
private readonly CancellationToken _stopCancellationToken;
public bool Stopped => _stopCancellationToken.IsCancellationRequested;
public event Func<VoiceGateway, Task> OnClosed = delegate { return Task.CompletedTask; };
public VoiceGateway(ulong guildId, ulong userId, string session, string token, string endpoint)
{
this._guildId = guildId;
this._userId = userId;
this._sessionId = session;
this._token = token;
this._endpoint = endpoint;
//Log.Information("g: {GuildId} u: {UserId} sess: {Session} tok: {Token} ep: {Endpoint}",
// guildId, userId, session, token, endpoint);
this._websocketUrl = new($"wss://{_endpoint.Replace(":80", "")}?v=4");
this._channel = Channel.CreateUnbounded<QueueItem>(new()
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false,
});
ConnectingFinished = new();
_rng = new();
_ws = new();
_udpClient = new();
_stopCancellationSource = new();
_stopCancellationToken = _stopCancellationSource.Token;
_ws.PayloadReceived += _ws_PayloadReceived;
_ws.WebsocketClosed += _ws_WebsocketClosed;
}
public Task WaitForReadyAsync()
=> ConnectingFinished.Task;
private async Task SendLoop()
{
while (!_stopCancellationToken.IsCancellationRequested)
{
try
{
var qi = await _channel.Reader.ReadAsync(_stopCancellationToken);
//Log.Information("Sending payload with opcode {OpCode}", qi.Payload.OpCode);
var json = JsonConvert.SerializeObject(qi.Payload);
if (!_stopCancellationToken.IsCancellationRequested)
await _ws.SendAsync(Encoding.UTF8.GetBytes(json));
_ = Task.Run(() => qi.Result.TrySetResult(true));
}
catch (ChannelClosedException)
{
Log.Warning("Voice gateway send channel is closed");
}
}
}
private async Task _ws_PayloadReceived(byte[] arg)
{
var payload = JsonConvert.DeserializeObject<VoicePayload>(Encoding.UTF8.GetString(arg));
if (payload is null)
return;
try
{
//Log.Information("Received payload with opcode {OpCode}", payload.OpCode);
switch (payload.OpCode)
{
case VoiceOpCode.Identify:
// sent, not received.
break;
case VoiceOpCode.SelectProtocol:
// sent, not received
break;
case VoiceOpCode.Ready:
var ready = payload.Data.ToObject<VoiceReady>();
await HandleReadyAsync(ready!);
_shouldResume = true;
break;
case VoiceOpCode.Heartbeat:
// sent, not received
break;
case VoiceOpCode.SessionDescription:
var sd = payload.Data.ToObject<VoiceSessionDescription>();
await HandleSessionDescription(sd!);
break;
case VoiceOpCode.Speaking:
// ignore for now
break;
case VoiceOpCode.HeartbeatAck:
_receivedAck = true;
break;
case VoiceOpCode.Resume:
// sent, not received
break;
case VoiceOpCode.Hello:
var hello = payload.Data.ToObject<VoiceHello>();
await HandleHelloAsync(hello!);
break;
case VoiceOpCode.Resumed:
_shouldResume = true;
break;
case VoiceOpCode.ClientDisconnect:
break;
}
}
catch (Exception ex)
{
Log.Error(ex, "Error handling payload with opcode {OpCode}: {Message}", payload.OpCode, ex.Message);
}
}
private Task _ws_WebsocketClosed(string arg)
{
if (!string.IsNullOrWhiteSpace(arg))
{
Log.Warning("Voice Websocket closed: {Arg}", arg);
}
var hbt = _heartbeatTimer;
hbt?.Change(Timeout.Infinite, Timeout.Infinite);
_heartbeatTimer = null;
if (!_stopCancellationToken.IsCancellationRequested && _shouldResume)
{
_ = _ws.RunAndBlockAsync(_websocketUrl, _stopCancellationToken);
return Task.CompletedTask;
}
_ws.WebsocketClosed -= _ws_WebsocketClosed;
_ws.PayloadReceived -= _ws_PayloadReceived;
if (!_stopCancellationToken.IsCancellationRequested)
_stopCancellationSource.Cancel();
return this.OnClosed(this);
}
public void SendRtpData(byte[] rtpData, int length)
=> _udpClient.Send(rtpData, length, _udpEp);
private Task HandleSessionDescription(VoiceSessionDescription sd)
{
SecretKey = sd.SecretKey;
Mode = sd.Mode;
_ = Task.Run(() => ConnectingFinished.TrySetResult(true));
return Task.CompletedTask;
}
private Task ResumeAsync()
{
_shouldResume = false;
return SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.Resume,
Data = JToken.FromObject(new VoiceResume
{
ServerId = this._guildId.ToString(),
SessionId = this._sessionId,
Token = this._token,
})
});
}
private async Task HandleReadyAsync(VoiceReady ready)
{
Ssrc = ready.Ssrc;
//Log.Information("Received ready {GuildId}, {Session}, {Token}", guildId, session, token);
_udpEp = new(IPAddress.Parse(ready.Ip), ready.Port);
var ssrcBytes = BitConverter.GetBytes(Ssrc);
Array.Reverse(ssrcBytes);
var ipDiscoveryData = new byte[74];
Buffer.BlockCopy(ssrcBytes, 0, ipDiscoveryData, 4, ssrcBytes.Length);
ipDiscoveryData[0] = 0x00;
ipDiscoveryData[1] = 0x01;
ipDiscoveryData[2] = 0x00;
ipDiscoveryData[3] = 0x46;
await _udpClient.SendAsync(ipDiscoveryData, ipDiscoveryData.Length, _udpEp);
while (true)
{
var buffer = _udpClient.Receive(ref _udpEp);
if (buffer.Length == 74)
{
//Log.Information("Received IP discovery data.");
var myIp = Encoding.UTF8.GetString(buffer, 8, buffer.Length - 10);
MyIp = myIp.TrimEnd('\0');
MyPort = (ushort)((buffer[^2] << 8) | buffer[^1]);
//Log.Information("{MyIp}:{MyPort}", MyIp, MyPort);
await SelectProtocol();
return;
}
//Log.Information("Received voice data");
}
}
private Task HandleHelloAsync(VoiceHello data)
{
_receivedAck = true;
_heartbeatTimer = new(async _ =>
{
await SendHeartbeatAsync();
}, default, data.HeartbeatInterval, data.HeartbeatInterval);
if (_shouldResume)
{
return ResumeAsync();
}
return IdentifyAsync();
}
private Task IdentifyAsync()
=> SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.Identify,
Data = JToken.FromObject(new VoiceIdentify
{
ServerId = _guildId.ToString(),
SessionId = _sessionId,
Token = _token,
UserId = _userId.ToString(),
})
});
private Task SelectProtocol()
=> SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.SelectProtocol,
Data = JToken.FromObject(new SelectProtocol
{
Protocol = "udp",
Data = new()
{
Address = MyIp,
Port = MyPort,
Mode = "xsalsa20_poly1305_lite",
}
})
});
private async Task SendHeartbeatAsync()
{
if (!_receivedAck)
{
Log.Warning("Voice gateway didn't receive HearbeatAck - closing");
var success = await _ws.CloseAsync();
if (!success)
await _ws_WebsocketClosed(null);
return;
}
_receivedAck = false;
await SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.Heartbeat,
Data = JToken.FromObject(_rng.Next())
});
}
public Task SendSpeakingAsync(VoiceSpeaking.State speaking)
=> SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.Speaking,
Data = JToken.FromObject(new VoiceSpeaking
{
Delay = 0,
Ssrc = Ssrc,
Speaking = (int)speaking
})
});
public Task StopAsync()
{
Started = false;
_shouldResume = false;
if (!_stopCancellationSource.IsCancellationRequested)
try { _stopCancellationSource.Cancel(); } catch { }
return _ws.CloseAsync("Stopped by the user.");
}
public Task Start()
{
Started = true;
_ = SendLoop();
return _ws.RunAndBlockAsync(_websocketUrl, _stopCancellationToken);
}
public bool Started { get; set; }
public async Task SendCommandPayloadAsync(VoicePayload payload)
{
var complete = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var queueItem = new QueueItem(payload, complete);
if (!_channel.Writer.TryWrite(queueItem))
await _channel.Writer.WriteAsync(queueItem);
await complete.Task;
}
}
}

View file

@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

1
src/EllieBot.VotesApi/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
store/

View file

@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace EllieBot.VotesApi
{
public class AuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "AUTHORIZATION_SCHEME";
public const string DiscordsClaim = "DISCORDS_CLAIM";
public const string TopggClaim = "TOPGG_CLAIM";
private readonly IConfiguration _conf;
public AuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration conf)
: base(options, logger, encoder)
=> _conf = conf;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim>();
if (_conf[ConfKeys.DISCORDS_KEY].Trim() == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new(DiscordsClaim, "true"));
if (_conf[ConfKeys.TOPGG_KEY] == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new Claim(TopggClaim, "true"));
return Task.FromResult(AuthenticateResult.Success(new(new(new ClaimsIdentity(claims)), SchemeName)));
}
}
}

View file

@ -0,0 +1,8 @@
namespace EllieBot.VotesApi
{
public static class ConfKeys
{
public const string DISCORDS_KEY = "DiscordsKey";
public const string TOPGG_KEY = "TopGGKey";
}
}

View file

@ -0,0 +1,26 @@
namespace EllieBot.VotesApi
{
public class DiscordsVoteWebhookModel
{
/// <summary>
/// The ID of the user who voted
/// </summary>
public string User { get; set; }
/// <summary>
/// The ID of the bot which recieved the vote
/// </summary>
public string Bot { get; set; }
/// <summary>
/// Contains totalVotes, votesMonth, votes24, hasVoted - a list of IDs of users who have voted this month, and
/// Voted24 - a list of IDs of users who have voted today
/// </summary>
public string Votes { get; set; }
/// <summary>
/// The type of event, whether it is a vote event or test event
/// </summary>
public string Type { get; set; }
}
}

View file

@ -0,0 +1,8 @@
namespace EllieBot.VotesApi
{
public static class Policies
{
public const string DiscordsAuth = "DiscordsAuth";
public const string TopggAuth = "TopggAuth";
}
}

View file

@ -0,0 +1,30 @@
namespace EllieBot.VotesApi
{
public class TopggVoteWebhookModel
{
/// <summary>
/// Discord ID of the bot that received a vote.
/// </summary>
public string Bot { get; set; }
/// <summary>
/// Discord ID of the user who voted.
/// </summary>
public string User { get; set; }
/// <summary>
/// The type of the vote (should always be "upvote" except when using the test button it's "test").
/// </summary>
public string Type { get; set; }
/// <summary>
/// Whether the weekend multiplier is in effect, meaning users votes count as two.
/// </summary>
public bool Weekend { get; set; }
/// <summary>
/// Query string params found on the /bot/:ID/vote page. Example: ?a=1&amp;b=2.
/// </summary>
public string Query { get; set; }
}
}

View file

@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class DiscordsController : ControllerBase
{
private readonly ILogger<DiscordsController> _logger;
private readonly IVotesCache _cache;
public DiscordsController(ILogger<DiscordsController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewDiscordsVotesAsync();
if (votes.Count > 0)
_logger.LogInformation("Sending {NewDiscordsVotes} new discords votes", votes.Count);
return votes;
}
}
}

View file

@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class TopGgController : ControllerBase
{
private readonly ILogger<TopGgController> _logger;
private readonly IVotesCache _cache;
public TopGgController(ILogger<TopGgController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewTopGgVotesAsync();
if (votes.Count > 0)
_logger.LogInformation("Sending {NewTopggVotes} new topgg votes", votes.Count);
return votes;
}
}
}

View file

@ -0,0 +1,48 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
public class WebhookController : ControllerBase
{
private readonly ILogger<WebhookController> _logger;
private readonly IVotesCache _votesCache;
public WebhookController(ILogger<WebhookController> logger, IVotesCache votesCache)
{
_logger = logger;
_votesCache = votesCache;
}
[HttpPost("/discordswebhook")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IActionResult> DiscordsWebhook([FromBody] DiscordsVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"discords.com");
await _votesCache.AddNewDiscordsVote(data.User);
return Ok();
}
[HttpPost("/topggwebhook")]
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IActionResult> TopggWebhook([FromBody] TopggVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"top.gg");
await _votesCache.AddNewTopggVote(data.User);
return Ok();
}
}
}

View file

@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
WORKDIR /src
COPY ["src/EllieBot.VotesApi/EllieBot.VotesApi.csproj", "EllieBot.VotesApi/"]
RUN dotnet restore "src/EllieBot.VotesApi/EllieBot.VotesApi.csproj"
COPY . .
WORKDIR "/src/EllieBot.VotesApi"
RUN dotnet build "EllieBot.VotesApi.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "EllieBot.VotesApi.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "EllieBot.VotesApi.dll"]

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MorseCode.ITask" Version="2.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using EllieBot.VotesApi;
CreateHostBuilder(args).Build().Run();
static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });

Some files were not shown because too many files have changed in this diff Show more