Upped version to 5.0.8
This commit is contained in:
commit
766e3d0ddd
977 changed files with 192319 additions and 485 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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/*
|
46
Dockerfile
46
Dockerfile
|
@ -0,0 +1,46 @@
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
|
||||||
|
WORKDIR /source
|
||||||
|
|
||||||
|
COPY src/Ellie.Marmalade/*.csproj src/Ellie.Marmalade/
|
||||||
|
COPY src/EllieBot/*.csproj src/EllieBot/
|
||||||
|
COPY src/EllieBot.Coordinator/*.csproj src/EllieBot.Coordinator/
|
||||||
|
COPY src/EllieBot.Generators/*.csproj src/EllieBot.Generators/
|
||||||
|
COPY src/EllieBot.Voice/*.csproj src/EllieBot.Voice/
|
||||||
|
COPY NuGet.Config ./
|
||||||
|
RUN dotnet restore src/EllieBot/
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
WORKDIR /source/src/EllieBot
|
||||||
|
RUN set -xe; \
|
||||||
|
dotnet --version; \
|
||||||
|
dotnet publish -c Release -o /app --no-restore; \
|
||||||
|
mv /app/data /app/data_init; \
|
||||||
|
rm -Rf libopus* libsodium* opus.* runtimes/win* runtimes/osx* runtimes/linux-arm* runtimes/linux-mips*; \
|
||||||
|
find /app -type f -exec chmod -x {} \; ;\
|
||||||
|
chmod +x /app/EllieBot
|
||||||
|
|
||||||
|
# final stage/image
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:6.0
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN set -xe; \
|
||||||
|
useradd -m ellie; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends libopus0 libsodium23 libsqlite3-0 curl ffmpeg python3 sudo; \
|
||||||
|
update-alternatives --install /usr/bin/python python /usr/bin/python3.9 1; \
|
||||||
|
echo 'Defaults>ellie env_keep+="ASPNETCORE_* DOTNET_* EllieBot_* shard_id total_shards TZ"' > /etc/sudoers.d/ellie; \
|
||||||
|
curl -Lo /usr/local/bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp; \
|
||||||
|
chmod a+rx /usr/local/bin/yt-dlp; \
|
||||||
|
apt-get autoremove -y; \
|
||||||
|
apt-get autoclean -y
|
||||||
|
|
||||||
|
COPY --from=build /app ./
|
||||||
|
COPY docker-entrypoint.sh /usr/local/sbin
|
||||||
|
|
||||||
|
ENV shard_id=0
|
||||||
|
ENV total_shards=1
|
||||||
|
ENV EllieBot__creds=/app/data/creds.yml
|
||||||
|
|
||||||
|
VOLUME [" /app/data "]
|
||||||
|
ENTRYPOINT [ "/usr/local/sbin/docker-entrypoint.sh" ]
|
||||||
|
CMD dotnet EllieBot.dll "$shard_id" "$total_shards"
|
66
EllieBot.sln
66
EllieBot.sln
|
@ -8,16 +8,28 @@ EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6C633450-E6C2-47ED-A7AA-7367232F703A}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6C633450-E6C2-47ED-A7AA-7367232F703A}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
CHANGELOG.md = CHANGELOG.md
|
CHANGELOG.md = CHANGELOG.md
|
||||||
Dockerfile = Dockerfile
|
|
||||||
LICENSE = LICENSE
|
LICENSE = LICENSE
|
||||||
README.md = README.md
|
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
|
EndProjectSection
|
||||||
EndProject
|
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
|
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
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
@ -25,22 +37,46 @@ Global
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Release|Any CPU.Build.0 = Release|Any CPU
|
{4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{179DF3B3-AD32-4335-8231-9818338DF3A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{179DF3B3-AD32-4335-8231-9818338DF3A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{179DF3B3-AD32-4335-8231-9818338DF3A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Release|Any CPU.Build.0 = Release|Any CPU
|
{179DF3B3-AD32-4335-8231-9818338DF3A2}.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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{BCB21472-84D2-4B63-B5DD-31E6A3EC9791} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
{4D9001F7-B3E8-48FE-97AA-CFD36DA65A64} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
||||||
{872A4C63-833C-4AE0-91AB-3CE348D3E6F8} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
{179DF3B3-AD32-4335-8231-9818338DF3A2} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
||||||
{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067} = {872A4C63-833C-4AE0-91AB-3CE348D3E6F8}
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4}
|
SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4}
|
||||||
|
|
6
NuGet.Config
Normal file
6
NuGet.Config
Normal 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>
|
|
@ -1,5 +1,7 @@
|
||||||
# Ellie
|
# Ellie
|
||||||
|
|
||||||
|
[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page)
|
||||||
|
|
||||||
## Small disclaimer
|
## 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.
|
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.
|
||||||
|
|
8
TODO.md
8
TODO.md
|
@ -1,3 +1,9 @@
|
||||||
# List of things to do
|
# 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
28
docker-entrypoint.sh
Normal 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
74
exe_builder.iss
Normal 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
9
migrate.ps1
Normal 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
3
remove-migrations.ps1
Normal 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
|
10
src/Ellie.Marmalade/Attributes/FilterAttribute.cs
Normal file
10
src/Ellie.Marmalade/Attributes/FilterAttribute.cs
Normal 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);
|
||||||
|
}
|
10
src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs
Normal file
10
src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs
Normal 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
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace EllieBot.Marmalade;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Method)]
|
||||||
|
public sealed class bot_owner_onlyAttribute : MarmaladePermAttribute
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
22
src/Ellie.Marmalade/Attributes/bot_permAttribute.cs
Normal file
22
src/Ellie.Marmalade/Attributes/bot_permAttribute.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
37
src/Ellie.Marmalade/Attributes/cmdAttribute.cs
Normal file
37
src/Ellie.Marmalade/Attributes/cmdAttribute.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
10
src/Ellie.Marmalade/Attributes/injectAttribute.cs
Normal file
10
src/Ellie.Marmalade/Attributes/injectAttribute.cs
Normal 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
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
10
src/Ellie.Marmalade/Attributes/leftoverAttribute.cs
Normal file
10
src/Ellie.Marmalade/Attributes/leftoverAttribute.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
namespace EllieBot.Marmalade;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the parameter to take
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Parameter)]
|
||||||
|
public class leftoverAttribute : Attribute
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
20
src/Ellie.Marmalade/Attributes/prioAttribute.cs
Normal file
20
src/Ellie.Marmalade/Attributes/prioAttribute.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
23
src/Ellie.Marmalade/Attributes/svcAttribute.cs
Normal file
23
src/Ellie.Marmalade/Attributes/svcAttribute.cs
Normal 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
|
||||||
|
}
|
22
src/Ellie.Marmalade/Attributes/user_permAttribute.cs
Normal file
22
src/Ellie.Marmalade/Attributes/user_permAttribute.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
143
src/Ellie.Marmalade/Canary.cs
Normal file
143
src/Ellie.Marmalade/Canary.cs
Normal 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
|
||||||
|
{
|
||||||
|
}
|
43
src/Ellie.Marmalade/Context/AnyContext.cs
Normal file
43
src/Ellie.Marmalade/Context/AnyContext.cs
Normal 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);
|
||||||
|
}
|
11
src/Ellie.Marmalade/Context/DmContext.cs
Normal file
11
src/Ellie.Marmalade/Context/DmContext.cs
Normal 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; }
|
||||||
|
}
|
12
src/Ellie.Marmalade/Context/GuildContext.cs
Normal file
12
src/Ellie.Marmalade/Context/GuildContext.cs
Normal 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; }
|
||||||
|
}
|
21
src/Ellie.Marmalade/Ellie.Marmalade.csproj
Normal file
21
src/Ellie.Marmalade/Ellie.Marmalade.csproj
Normal 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>
|
8
src/Ellie.Marmalade/EmbedColor.cs
Normal file
8
src/Ellie.Marmalade/EmbedColor.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot;
|
||||||
|
|
||||||
|
public enum EmbedColor
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
Pending,
|
||||||
|
Error
|
||||||
|
}
|
61
src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs
Normal file
61
src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs
Normal 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)}");
|
||||||
|
}
|
18
src/Ellie.Marmalade/IEmbedBuilder.cs
Normal file
18
src/Ellie.Marmalade/IEmbedBuilder.cs
Normal 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);
|
||||||
|
}
|
16
src/Ellie.Marmalade/ParamParser/ParamParser.cs
Normal file
16
src/Ellie.Marmalade/ParamParser/ParamParser.cs
Normal 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);
|
||||||
|
}
|
48
src/Ellie.Marmalade/ParamParser/ParseResult.cs
Normal file
48
src/Ellie.Marmalade/ParamParser/ParseResult.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
1
src/Ellie.Marmalade/README.md
Normal file
1
src/Ellie.Marmalade/README.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
This is the library which is the base of any marmalade.
|
24
src/Ellie.Marmalade/Strings/CommandStrings.cs
Normal file
24
src/Ellie.Marmalade/Strings/CommandStrings.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
15
src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs
Normal file
15
src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs
Normal 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);
|
||||||
|
}
|
28
src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs
Normal file
28
src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs
Normal 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);
|
||||||
|
}
|
40
src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs
Normal file
40
src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
79
src/Ellie.Marmalade/Strings/MarmaladeStrings.cs
Normal file
79
src/Ellie.Marmalade/Strings/MarmaladeStrings.cs
Normal 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();
|
||||||
|
}
|
137
src/Ellie.Marmalade/Strings/StringsLoader.cs
Normal file
137
src/Ellie.Marmalade/Strings/StringsLoader.cs
Normal 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);
|
||||||
|
}
|
2
src/Ellie.Marmalade/pack-and-push.ps1
Normal file
2
src/Ellie.Marmalade/pack-and-push.ps1
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
dotnet pack -o bin/Release/packed
|
||||||
|
dotnet nuget push bin/Release/packed/ --source emotionlab
|
47
src/EllieBot.Coordinator/CoordStartup.cs
Normal file
47
src/EllieBot.Coordinator/CoordStartup.cs
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
src/EllieBot.Coordinator/EllieBot.Coordinator.csproj
Normal file
19
src/EllieBot.Coordinator/EllieBot.Coordinator.csproj
Normal 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>
|
43
src/EllieBot.Coordinator/LogSetup.cs
Normal file
43
src/EllieBot.Coordinator/LogSetup.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
src/EllieBot.Coordinator/Program.cs
Normal file
20
src/EllieBot.Coordinator/Program.cs
Normal 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();
|
13
src/EllieBot.Coordinator/Properties/launchSettings.json
Normal file
13
src/EllieBot.Coordinator/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
127
src/EllieBot.Coordinator/Protos/coordinator.proto
Normal file
127
src/EllieBot.Coordinator/Protos/coordinator.proto
Normal 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;
|
||||||
|
}
|
11
src/EllieBot.Coordinator/README.md
Normal file
11
src/EllieBot.Coordinator/README.md
Normal 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
|
456
src/EllieBot.Coordinator/Services/CoordinatorRunner.cs
Normal file
456
src/EllieBot.Coordinator/Services/CoordinatorRunner.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
144
src/EllieBot.Coordinator/Services/CoordinatorService.cs
Normal file
144
src/EllieBot.Coordinator/Services/CoordinatorService.cs
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
src/EllieBot.Coordinator/Shared/Config.cs
Normal file
21
src/EllieBot.Coordinator/Shared/Config.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
9
src/EllieBot.Coordinator/Shared/CoordState.cs
Normal file
9
src/EllieBot.Coordinator/Shared/CoordState.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace EllieBot.Coordinator
|
||||||
|
{
|
||||||
|
public class CoordState
|
||||||
|
{
|
||||||
|
public List<JsonStatusObject> StatusObjects { get; init; }
|
||||||
|
}
|
||||||
|
}
|
9
src/EllieBot.Coordinator/Shared/JsonStatusObject.cs
Normal file
9
src/EllieBot.Coordinator/Shared/JsonStatusObject.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
15
src/EllieBot.Coordinator/Shared/ShardStatus.cs
Normal file
15
src/EllieBot.Coordinator/Shared/ShardStatus.cs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
9
src/EllieBot.Coordinator/appsettings.Development.json
Normal file
9
src/EllieBot.Coordinator/appsettings.Development.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
src/EllieBot.Coordinator/appsettings.json
Normal file
20
src/EllieBot.Coordinator/appsettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
src/EllieBot.Coordinator/coord.yml
Normal file
12
src/EllieBot.Coordinator/coord.yml
Normal 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
|
258
src/EllieBot.Generators/Cloneable/CloneableGenerator.cs
Normal file
258
src/EllieBot.Generators/Cloneable/CloneableGenerator.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
src/EllieBot.Generators/Cloneable/SymbolExtensions.cs
Normal file
23
src/EllieBot.Generators/Cloneable/SymbolExtensions.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
27
src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs
Normal file
27
src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
src/EllieBot.Generators/EllieBot.Generators.csproj
Normal file
26
src/EllieBot.Generators/EllieBot.Generators.csproj
Normal 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>
|
140
src/EllieBot.Generators/LocalizedStringsGenerator.cs
Normal file
140
src/EllieBot.Generators/LocalizedStringsGenerator.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
src/EllieBot.Generators/README.md
Normal file
24
src/EllieBot.Generators/README.md
Normal 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"
|
||||||
|
|
131
src/EllieBot.Tests/BotStringsTests.cs
Normal file
131
src/EllieBot.Tests/BotStringsTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
93
src/EllieBot.Tests/ConcurrentHashSetTests.cs
Normal file
93
src/EllieBot.Tests/ConcurrentHashSetTests.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
18
src/EllieBot.Tests/EllieBot.Tests.csproj
Normal file
18
src/EllieBot.Tests/EllieBot.Tests.csproj
Normal 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>
|
76
src/EllieBot.Tests/GroupGreetTests.cs
Normal file
76
src/EllieBot.Tests/GroupGreetTests.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
188
src/EllieBot.Tests/IndexedCollectionTests.cs
Normal file
188
src/EllieBot.Tests/IndexedCollectionTests.cs
Normal 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 }));
|
||||||
|
}
|
||||||
|
}
|
124
src/EllieBot.Tests/KwumTests.cs
Normal file
124
src/EllieBot.Tests/KwumTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
84
src/EllieBot.Tests/NewDeckTests.cs
Normal file
84
src/EllieBot.Tests/NewDeckTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
136
src/EllieBot.Tests/PubSubTests.cs
Normal file
136
src/EllieBot.Tests/PubSubTests.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
src/EllieBot.Tests/README.md
Normal file
1
src/EllieBot.Tests/README.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Project which contains tests. Self explanatory
|
23
src/EllieBot.Tests/Random.cs
Normal file
23
src/EllieBot.Tests/Random.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
src/EllieBot.Voice/CloseCodes.cs
Normal file
35
src/EllieBot.Voice/CloseCodes.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,15 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk" ToolsVersion="Current">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.1</TargetFramework>
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
<LangVersion>9.0</LangVersion>
|
<LangVersion>9.0</LangVersion>
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
<NoWarn>CS8632</NoWarn>
|
<NoWarn>CS8632</NoWarn>
|
||||||
<Version>1.0.2</Version>
|
<Version>1.0.2</Version>
|
||||||
|
<RootNamespace>EllieBot.Voice</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="Serilog" Version="2.11.0" />
|
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||||
<PackageReference Include="System.Threading.Channels" Version="6.0.0" />
|
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
|
@ -1,11 +1,11 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice
|
namespace EllieBot.Voice
|
||||||
{
|
{
|
||||||
internal static unsafe class LibOpus
|
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)]
|
[DllImport(OPUS, EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)]
|
||||||
internal static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error);
|
internal static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error);
|
|
@ -1,11 +1,11 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice
|
namespace EllieBot.Voice
|
||||||
{
|
{
|
||||||
internal static unsafe class Sodium
|
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)]
|
[DllImport(SODIUM, EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)]
|
||||||
private static extern int SecretBoxEasy(byte* output, byte* input, long inputLength, byte* nonce, byte* secret);
|
private static extern int SecretBoxEasy(byte* output, byte* input, long inputLength, byte* nonce, byte* secret);
|
|
@ -1,6 +1,6 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice.Models
|
namespace EllieBot.Voice.Models
|
||||||
{
|
{
|
||||||
public sealed class SelectProtocol
|
public sealed class SelectProtocol
|
||||||
{
|
{
|
|
@ -1,6 +1,6 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice.Models
|
namespace EllieBot.Voice.Models
|
||||||
{
|
{
|
||||||
public sealed class VoiceHello
|
public sealed class VoiceHello
|
||||||
{
|
{
|
|
@ -1,6 +1,6 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice.Models
|
namespace EllieBot.Voice.Models
|
||||||
{
|
{
|
||||||
public sealed class VoiceIdentify
|
public sealed class VoiceIdentify
|
||||||
{
|
{
|
|
@ -1,6 +1,6 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice.Models
|
namespace EllieBot.Voice.Models
|
||||||
{
|
{
|
||||||
public sealed class VoiceReady
|
public sealed class VoiceReady
|
||||||
{
|
{
|
|
@ -1,6 +1,6 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice.Models
|
namespace EllieBot.Voice.Models
|
||||||
{
|
{
|
||||||
public sealed class VoiceResume
|
public sealed class VoiceResume
|
||||||
{
|
{
|
|
@ -1,6 +1,6 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice.Models
|
namespace EllieBot.Voice.Models
|
||||||
{
|
{
|
||||||
public sealed class VoiceSessionDescription
|
public sealed class VoiceSessionDescription
|
||||||
{
|
{
|
|
@ -1,7 +1,7 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice.Models
|
namespace EllieBot.Voice.Models
|
||||||
{
|
{
|
||||||
public sealed class VoiceSpeaking
|
public sealed class VoiceSpeaking
|
||||||
{
|
{
|
|
@ -4,7 +4,7 @@ using System.Buffers;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice
|
namespace EllieBot.Voice
|
||||||
{
|
{
|
||||||
public sealed class PoopyBufferImmortalized : ISongBuffer
|
public sealed class PoopyBufferImmortalized : ISongBuffer
|
||||||
{
|
{
|
|
@ -4,7 +4,7 @@ using System.Diagnostics;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice
|
namespace EllieBot.Voice
|
||||||
{
|
{
|
||||||
public interface ISongBuffer : IDisposable
|
public interface ISongBuffer : IDisposable
|
||||||
{
|
{
|
|
@ -1,7 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
|
|
||||||
namespace Ayu.Discord.Voice
|
namespace EllieBot.Voice
|
||||||
{
|
{
|
||||||
public sealed class VoiceClient : IDisposable
|
public sealed class VoiceClient : IDisposable
|
||||||
{
|
{
|
375
src/EllieBot.Voice/VoiceGateway.cs
Normal file
375
src/EllieBot.Voice/VoiceGateway.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
src/EllieBot.VotesApi/.dockerignore
Normal file
25
src/EllieBot.VotesApi/.dockerignore
Normal 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
1
src/EllieBot.VotesApi/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
store/
|
40
src/EllieBot.VotesApi/Common/AuthHandler.cs
Normal file
40
src/EllieBot.VotesApi/Common/AuthHandler.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
8
src/EllieBot.VotesApi/Common/ConfKeys.cs
Normal file
8
src/EllieBot.VotesApi/Common/ConfKeys.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot.VotesApi
|
||||||
|
{
|
||||||
|
public static class ConfKeys
|
||||||
|
{
|
||||||
|
public const string DISCORDS_KEY = "DiscordsKey";
|
||||||
|
public const string TOPGG_KEY = "TopGGKey";
|
||||||
|
}
|
||||||
|
}
|
26
src/EllieBot.VotesApi/Common/DiscordsVoteWebhookModel.cs
Normal file
26
src/EllieBot.VotesApi/Common/DiscordsVoteWebhookModel.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
8
src/EllieBot.VotesApi/Common/Policies.cs
Normal file
8
src/EllieBot.VotesApi/Common/Policies.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot.VotesApi
|
||||||
|
{
|
||||||
|
public static class Policies
|
||||||
|
{
|
||||||
|
public const string DiscordsAuth = "DiscordsAuth";
|
||||||
|
public const string TopggAuth = "TopggAuth";
|
||||||
|
}
|
||||||
|
}
|
30
src/EllieBot.VotesApi/Common/TopggVoteWebhookModel.cs
Normal file
30
src/EllieBot.VotesApi/Common/TopggVoteWebhookModel.cs
Normal 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&b=2.
|
||||||
|
/// </summary>
|
||||||
|
public string Query { get; set; }
|
||||||
|
}
|
||||||
|
}
|
33
src/EllieBot.VotesApi/Controllers/DiscordsController.cs
Normal file
33
src/EllieBot.VotesApi/Controllers/DiscordsController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
src/EllieBot.VotesApi/Controllers/TopGgController.cs
Normal file
34
src/EllieBot.VotesApi/Controllers/TopGgController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
48
src/EllieBot.VotesApi/Controllers/WebhookController.cs
Normal file
48
src/EllieBot.VotesApi/Controllers/WebhookController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
src/EllieBot.VotesApi/Dockerfile
Normal file
20
src/EllieBot.VotesApi/Dockerfile
Normal 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"]
|
13
src/EllieBot.VotesApi/EllieBot.VotesApi.csproj
Normal file
13
src/EllieBot.VotesApi/EllieBot.VotesApi.csproj
Normal 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>
|
9
src/EllieBot.VotesApi/Program.cs
Normal file
9
src/EllieBot.VotesApi/Program.cs
Normal 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
Loading…
Reference in a new issue