diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c84eea1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Ignore all files +* + +# Don't ignore nugetconfig +!./NuGet.Config + +# Don't ignore src projects +!src/** +!docker-entrypoint.sh + +# ignore bin and obj folders in projects +src/**/bin/* +src/**/obj/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e69de29..dcdedcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /source + +COPY src/Ellie.Marmalade/*.csproj src/Ellie.Marmalade/ +COPY src/EllieBot/*.csproj src/EllieBot/ +COPY src/EllieBot.Coordinator/*.csproj src/EllieBot.Coordinator/ +COPY src/EllieBot.Generators/*.csproj src/EllieBot.Generators/ +COPY src/EllieBot.Voice/*.csproj src/EllieBot.Voice/ +COPY NuGet.Config ./ +RUN dotnet restore src/EllieBot/ + +COPY . . +WORKDIR /source/src/EllieBot +RUN set -xe; \ + dotnet --version; \ + dotnet publish -c Release -o /app --no-restore; \ + mv /app/data /app/data_init; \ + rm -Rf libopus* libsodium* opus.* runtimes/win* runtimes/osx* runtimes/linux-arm* runtimes/linux-mips*; \ + find /app -type f -exec chmod -x {} \; ;\ + chmod +x /app/EllieBot + +# final stage/image +FROM mcr.microsoft.com/dotnet/runtime:6.0 +WORKDIR /app + +RUN set -xe; \ + useradd -m ellie; \ + apt-get update; \ + apt-get install -y --no-install-recommends libopus0 libsodium23 libsqlite3-0 curl ffmpeg python3 sudo; \ + update-alternatives --install /usr/bin/python python /usr/bin/python3.9 1; \ + echo 'Defaults>ellie env_keep+="ASPNETCORE_* DOTNET_* EllieBot_* shard_id total_shards TZ"' > /etc/sudoers.d/ellie; \ + curl -Lo /usr/local/bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp; \ + chmod a+rx /usr/local/bin/yt-dlp; \ + apt-get autoremove -y; \ + apt-get autoclean -y + +COPY --from=build /app ./ +COPY docker-entrypoint.sh /usr/local/sbin + +ENV shard_id=0 +ENV total_shards=1 +ENV EllieBot__creds=/app/data/creds.yml + +VOLUME [" /app/data "] +ENTRYPOINT [ "/usr/local/sbin/docker-entrypoint.sh" ] +CMD dotnet EllieBot.dll "$shard_id" "$total_shards" diff --git a/EllieBot.sln b/EllieBot.sln index c2985a3..4a3f164 100644 --- a/EllieBot.sln +++ b/EllieBot.sln @@ -8,16 +8,28 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6C633450-E6C2-47ED-A7AA-7367232F703A}" ProjectSection(SolutionItems) = preProject CHANGELOG.md = CHANGELOG.md - Dockerfile = Dockerfile LICENSE = LICENSE README.md = README.md + Dockerfile = Dockerfile + NuGet.Config = NuGet.Config + migrate.ps1 = migrate.ps1 + remove-migrations.ps1 = remove-migrations.ps1 + TODO.md = TODO.md EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot", "src\EllieBot\EllieBot.csproj", "{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot", "src\EllieBot\EllieBot.csproj", "{4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ayu", "ayu", "{872A4C63-833C-4AE0-91AB-3CE348D3E6F8}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Tests", "src\EllieBot.Tests\EllieBot.Tests.csproj", "{179DF3B3-AD32-4335-8231-9818338DF3A2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ayu.Discord.Voice", "src\ayu\Ayu.Discord.Voice\Ayu.Discord.Voice.csproj", "{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Coordinator", "src\EllieBot.Coordinator\EllieBot.Coordinator.csproj", "{A631DDF0-3AD1-4CB9-8458-314B1320868A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Generators", "src\EllieBot.Generators\EllieBot.Generators.csproj", "{CB1A5307-DD85-4795-8A8A-A25D36DADC51}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.VotesApi", "src\EllieBot.VotesApi\EllieBot.VotesApi.csproj", "{F1A77F56-71B0-430E-AE46-94CDD7D43874}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Marmalade", "src\Ellie.Marmalade\Ellie.Marmalade.csproj", "{76AC715D-12FF-4CBE-9585-A861139A2D0C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Voice", "src\EllieBot.Voice\EllieBot.Voice.csproj", "{1D93CE3C-80B4-49C7-A9A2-99988920AAEC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -25,22 +37,46 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Release|Any CPU.Build.0 = Release|Any CPU - {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067}.Release|Any CPU.Build.0 = Release|Any CPU + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Release|Any CPU.Build.0 = Release|Any CPU + {179DF3B3-AD32-4335-8231-9818338DF3A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {179DF3B3-AD32-4335-8231-9818338DF3A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {179DF3B3-AD32-4335-8231-9818338DF3A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {179DF3B3-AD32-4335-8231-9818338DF3A2}.Release|Any CPU.Build.0 = Release|Any CPU + {A631DDF0-3AD1-4CB9-8458-314B1320868A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A631DDF0-3AD1-4CB9-8458-314B1320868A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A631DDF0-3AD1-4CB9-8458-314B1320868A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A631DDF0-3AD1-4CB9-8458-314B1320868A}.Release|Any CPU.Build.0 = Release|Any CPU + {CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Release|Any CPU.Build.0 = Release|Any CPU + {F1A77F56-71B0-430E-AE46-94CDD7D43874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1A77F56-71B0-430E-AE46-94CDD7D43874}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1A77F56-71B0-430E-AE46-94CDD7D43874}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1A77F56-71B0-430E-AE46-94CDD7D43874}.Release|Any CPU.Build.0 = Release|Any CPU + {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Release|Any CPU.Build.0 = Release|Any CPU + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} - {872A4C63-833C-4AE0-91AB-3CE348D3E6F8} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} - {5AD2EFFB-7774-49B2-A791-3BAC4DAEE067} = {872A4C63-833C-4AE0-91AB-3CE348D3E6F8} + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {179DF3B3-AD32-4335-8231-9818338DF3A2} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {A631DDF0-3AD1-4CB9-8458-314B1320868A} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {CB1A5307-DD85-4795-8A8A-A25D36DADC51} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {F1A77F56-71B0-430E-AE46-94CDD7D43874} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {76AC715D-12FF-4CBE-9585-A861139A2D0C} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4} diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..7e64704 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,6 @@ + + + + + + diff --git a/README.md b/README.md index 0f9c1bd..452254f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Ellie +[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page) + ## Small disclaimer All the code in this repo may not be production ready yet and if you want to try and run a version of this by yourself you are on your own. diff --git a/TODO.md b/TODO.md index 6227514..5b6c748 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,9 @@ # List of things to do - - Finish the full system rewrite \ No newline at end of file + - ~~Finish the Ellie.Marmalade project~~ Done + - ~~Finish the EllieBot.Tests project~~ Done + - ~~Finish the EllieBot project~~ Done + - ~~Finish the EllieBot.Coordinator project~~ Done + - ~~Finish the EllieBot.Generators project~~ Done + - ~~Finish the EllieBot.Voice project~~ Done + - ~~Finish the EllieBot.VotesApi project~~ Done \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..3f3fbd3 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -e; + +data_init=/app/data_init +data=/app/data + +# populate /app/data if empty +for i in $(ls $data_init) +do + if [ ! -e "$data/$i" ]; then + [ -f "$data_init/$i" ] && cp "$data_init/$i" "$data/$i" + [ -d "$data_init/$i" ] && cp -r "$data_init/$i" "$data/$i" + fi +done + +# creds.yml migration +if [ -f /app/creds.yml ]; then + echo "Default location for creds.yml is now /app/data/creds.yml." + echo "Please move your creds.yml and update your docker-compose.yml accordingly." + + export Ellie_creds=/app/creds.yml +fi + +# ensure ellie can write on /app/data +chown -R ellie:ellie "$data" + +# drop to regular user and launch command +exec sudo -u ellie "$@" \ No newline at end of file diff --git a/exe_builder.iss b/exe_builder.iss new file mode 100644 index 0000000..9ef8bb9 --- /dev/null +++ b/exe_builder.iss @@ -0,0 +1,74 @@ +#define sysfolder "system" +#define version GetEnv("ELLIEBOT_INSTALL_VERSION") +#define target "win-x64" +#define platform "net8.0" + +[Setup] +AppName = {param:botname|EllieBot} +AppVersion={#version} +AppPublisher=Toastie +DefaultDirName={param:installpath|{commonpf}\EllieBot} +DefaultGroupName=EllieBot +UninstallDisplayIcon={app}\{#sysfolder}\ellie_icon.ico +Compression=lzma2 +SolidCompression=yes +UsePreviousLanguage=no +UsePreviousSetupType=no +UsePreviousAppDir=no +OutputDir=ellie-installers/{#version}/ +OutputBaseFilename=ellie-setup-{#version} +AppReadmeFile=https://commands.elliebot.net/ +ArchitecturesInstallIn64BitMode=x64 +DisableWelcomePage=yes +DisableDirPage=yes +DisableFinishedPage=yes +DisableReadyMemo=yes +DisableProgramGroupPage=yes +WizardStyle=modern +UpdateUninstallLogAppName=no +CreateUninstallRegKey=no +Uninstallable=no + +[Files] +;install +Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\*"; DestDir: "{app}\{#sysfolder}"; Permissions: users-full; Flags: recursesubdirs onlyifdoesntexist ignoreversion createallsubdirs; Excludes: "*.pdb, *.db" + +;reinstall - i want to copy all files, but i don't want to overwrite any data files because users will lose their customization if they don't have a backup, +; and i don't want them to have to backup and then copy-merge into data folder themselves, or lose their currency images due to overwrite. +Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\*"; DestDir: "{app}\{#sysfolder}"; Permissions: users-full; Flags: recursesubdirs ignoreversion onlyifdestfileexists createallsubdirs; Excludes: "*.pdb, *.db, data\*, credentials.json, creds.yml"; +Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\data\*"; DestDir: "{app}\{#sysfolder}\data"; Permissions: users-full; Flags: recursesubdirs onlyifdoesntexist createallsubdirs; +; overwrite strings and aliases +Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\data\aliases.yml"; DestDir: "{app}\{#sysfolder}\data\"; Permissions: users-full; Flags: recursesubdirs ignoreversion onlyifdestfileexists createallsubdirs; +Source: "src\EllieBot\bin\Release\{#platform}\{#target}\publish\data\strings\*"; DestDir: "{app}\{#sysfolder}\data\strings"; Permissions: users-full; Flags: recursesubdirs ignoreversion onlyifdestfileexists createallsubdirs; + +[Dirs] +Name:"{app}\{#sysfolder}\data"; Permissions: everyone-modify +Name:"{app}\{#sysfolder}\config"; Permissions: everyone-modify +Name:"{app}\{#sysfolder}"; Permissions: everyone-modify + +; [Run] +; Filename: "https://docs.elliebot.net/ellie/"; Flags: postinstall shellexec runasoriginaluser; Description: "Open setup guide" +; Filename: "{app}\{#sysfolder}\creds.yml"; Flags: postinstall shellexec runasoriginaluser; Description: "Open creds file" + +[Icons] +; for pretty install directory +Name: "{app}\EllieBot"; Filename: "{app}\{#sysfolder}\EllieBot.exe"; IconFilename: "{app}\{#sysfolder}\nadeko_icon.ico" +Name: "{app}\creds"; Filename: "{app}\{#sysfolder}\creds.yml" +Name: "{app}\data"; Filename: "{app}\{#sysfolder}\data" + +; desktop shortcut +Name: "{commondesktop}\{#SetupSetting("AppName")}"; Filename: "{app}\EllieBot"; + +[Code] +function GetFileName(const AFileName: string): string; +begin + Result := ExpandConstant('{app}\{#sysfolder}\' + AFileName); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if (CurStep = ssPostInstall) then + begin + FileCopy(GetFileName('creds_example.yml'), GetFileName('creds.yml'), True); + end; +end; \ No newline at end of file diff --git a/migrate.ps1 b/migrate.ps1 new file mode 100644 index 0000000..a5ff6c4 --- /dev/null +++ b/migrate.ps1 @@ -0,0 +1,9 @@ +if ($args.Length -eq 0) { + Write-Host "Please provide a migration name." -ForegroundColor Red +} +else { + $migrationName = $args[0] + dotnet ef migrations add $migrationName -c SqliteContext -p src/EllieBot/EllieBot.csproj + dotnet ef migrations add $migrationName -c PostgreSqlContext -p src/EllieBot/EllieBot.csproj + dotnet ef migrations add $migrationName -c MysqlContext -p src/EllieBot/EllieBot.csproj +} \ No newline at end of file diff --git a/remove-migrations.ps1 b/remove-migrations.ps1 new file mode 100644 index 0000000..5445dbb --- /dev/null +++ b/remove-migrations.ps1 @@ -0,0 +1,3 @@ +dotnet ef migrations remove -c SqliteContext -f -p src/EllieBot/EllieBot.csproj +dotnet ef migrations remove -c PostgreSqlContext -f -p src/EllieBot/EllieBot.csproj +dotnet ef migrations remove -c MysqlContext -f -p src/EllieBot/EllieBot.csproj \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/FilterAttribute.cs b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs new file mode 100644 index 0000000..d5a428b --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Marmalade; + +/// +/// Overridden to implement custom checks which commands have to pass in order to be executed. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] +public abstract class FilterAttribute : Attribute +{ + public abstract ValueTask CheckAsync(AnyContext ctx); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs b/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs new file mode 100644 index 0000000..0f04c23 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Marmalade; + +/// +/// Used as a marker class for bot_perm and user_perm Attributes +/// Has no functionality. +/// +public abstract class MarmaladePermAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs b/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs new file mode 100644 index 0000000..31c3cfd --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Marmalade; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class bot_owner_onlyAttribute : MarmaladePermAttribute +{ + +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs new file mode 100644 index 0000000..6bd6af1 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs @@ -0,0 +1,22 @@ +using Discord; + +namespace EllieBot.Marmalade; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class bot_permAttribute : MarmaladePermAttribute +{ + public GuildPermission? GuildPerm { get; } + public ChannelPermission? ChannelPerm { get; } + + public bot_permAttribute(GuildPermission perm) + { + GuildPerm = perm; + ChannelPerm = null; + } + + public bot_permAttribute(ChannelPermission perm) + { + ChannelPerm = perm; + GuildPerm = null; + } +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Attributes/cmdAttribute.cs b/src/Ellie.Marmalade/Attributes/cmdAttribute.cs new file mode 100644 index 0000000..0dc068e --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/cmdAttribute.cs @@ -0,0 +1,37 @@ +namespace EllieBot.Marmalade; + +/// +/// Marks a method as a snek command +/// +[AttributeUsage(AttributeTargets.Method)] +public class cmdAttribute : Attribute +{ + /// + /// Command description. Avoid using, as cmds.yml is preferred + /// + public string? desc { get; set; } + + /// + /// Command args examples. Avoid using, as cmds.yml is preferred + /// + public string[]? args { get; set; } + + /// + /// Command aliases + /// + public string[] Aliases { get; } + + public cmdAttribute() + { + desc = null; + args = null; + Aliases = Array.Empty(); + } + + public cmdAttribute(params string[] aliases) + { + Aliases = aliases; + desc = null; + args = null; + } +} diff --git a/src/Ellie.Marmalade/Attributes/injectAttribute.cs b/src/Ellie.Marmalade/Attributes/injectAttribute.cs new file mode 100644 index 0000000..be843ae --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/injectAttribute.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Marmalade; + +/// +/// Marks services in command arguments for injection. +/// The injected services must come after the context and before any input parameters. +/// +public class injectAttribute : Attribute +{ + +} diff --git a/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs b/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs new file mode 100644 index 0000000..71543e2 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Marmalade; + +/// +/// Marks the parameter to take +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class leftoverAttribute : Attribute +{ + +} diff --git a/src/Ellie.Marmalade/Attributes/prioAttribute.cs b/src/Ellie.Marmalade/Attributes/prioAttribute.cs new file mode 100644 index 0000000..2868b23 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/prioAttribute.cs @@ -0,0 +1,20 @@ +namespace EllieBot.Marmalade; + +/// +/// Sets the priority of a command in case there are multiple commands with the same name but different parameters. +/// Higher value means higher priority. +/// +[AttributeUsage(AttributeTargets.Method)] +public class prioAttribute : Attribute +{ + public int Priority { get; } + + /// + /// Snek command priority + /// + /// Priority value. The higher the value, the higher the priority + public prioAttribute(int priority) + { + Priority = priority; + } +} diff --git a/src/Ellie.Marmalade/Attributes/svcAttribute.cs b/src/Ellie.Marmalade/Attributes/svcAttribute.cs new file mode 100644 index 0000000..a453303 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/svcAttribute.cs @@ -0,0 +1,23 @@ +namespace EllieBot.Marmalade; + +/// +/// Marks the class as a service which can be used within the same Medusa +/// +[AttributeUsage(AttributeTargets.Class)] +public class svcAttribute : Attribute +{ + public Lifetime Lifetime { get; } + public svcAttribute(Lifetime lifetime) + { + Lifetime = lifetime; + } +} + +/// +/// Lifetime for +/// +public enum Lifetime +{ + Singleton, + Transient +} diff --git a/src/Ellie.Marmalade/Attributes/user_permAttribute.cs b/src/Ellie.Marmalade/Attributes/user_permAttribute.cs new file mode 100644 index 0000000..b0a3aa3 --- /dev/null +++ b/src/Ellie.Marmalade/Attributes/user_permAttribute.cs @@ -0,0 +1,22 @@ +using Discord; + +namespace EllieBot.Marmalade; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class user_permAttribute : MarmaladePermAttribute +{ + public GuildPermission? GuildPerm { get; } + public ChannelPermission? ChannelPerm { get; } + + public user_permAttribute(GuildPermission perm) + { + GuildPerm = perm; + ChannelPerm = null; + } + + public user_permAttribute(ChannelPermission perm) + { + ChannelPerm = perm; + GuildPerm = null; + } +} diff --git a/src/Ellie.Marmalade/Canary.cs b/src/Ellie.Marmalade/Canary.cs new file mode 100644 index 0000000..4b7bbbb --- /dev/null +++ b/src/Ellie.Marmalade/Canary.cs @@ -0,0 +1,143 @@ +using Discord; + +namespace EllieBot.Marmalade; + +/// +/// The base class which will be loaded as a module into EllieBot +/// Any user-defined canary has to inherit from this class. +/// Canaries get instantiated ONLY ONCE during the loading, +/// and any canary commands will be executed on the same instance. +/// +public abstract class Canary : IAsyncDisposable +{ + /// + /// Name of the canary. Defaults to the lowercase class name + /// + public virtual string Name + => GetType().Name.ToLowerInvariant(); + + /// + /// The prefix required before the command name. For example + /// if you set this to 'test' then a command called 'cmd' will have to be invoked by using + /// '.test cmd' instead of `.cmd` + /// + public virtual string Prefix + => string.Empty; + + /// + /// Executed once this canary has been instantiated and before any command is executed. + /// + /// A representing completion + public virtual ValueTask InitializeAsync() + => default; + + /// + /// Override to cleanup any resources or references which might hold this canary in memory + /// + /// + public virtual ValueTask DisposeAsync() + => default; + + /// + /// This method is called right after the message was received by the bot. + /// You can use this method to make the bot conditionally ignore some messages and prevent further processing. + /// Execution order: + /// + /// ** → + /// → + /// → + /// OR + /// + /// + /// Guild in which the message was sent + /// Message received by the bot + /// A representing whether the message should be ignored and not processed further + public virtual ValueTask ExecOnMessageAsync(IGuild? guild, IUserMessage msg) + => default; + + /// + /// Override this method to modify input before the bot searches for any commands matching the input + /// Executed after + /// This is useful if you want to reinterpret the message under some conditions + /// Execution order: + /// + /// → + /// ** → + /// → + /// OR + /// + /// + /// Guild in which the message was sent + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// A representing new, potentially modified content + public virtual ValueTask ExecInputTransformAsync( + IGuild? guild, + IMessageChannel channel, + IUser user, + string input + ) + => default; + + /// + /// This method is called after the command was found but not executed, + /// and can be used to prevent the command's execution. + /// The command information doesn't have to be from this canary as this method + /// will be called when *any* command from any module or canary was found. + /// You can choose to prevent the execution of the command by returning "true" value. + /// Execution order: + /// + /// → + /// → + /// ** → + /// OR + /// + /// + /// Command context + /// Name of the canary or module from which the command originates + /// Name of the command which is about to be executed + /// A representing whether the execution should be blocked + public virtual ValueTask ExecPreCommandAsync( + AnyContext context, + string moduleName, + string commandName + ) + => default; + + /// + /// This method is called after the command was succesfully executed. + /// If this method was called, then will not be executed + /// Execution order: + /// + /// → + /// → + /// → + /// ** OR + /// + /// + /// A representing completion + public virtual ValueTask ExecPostCommandAsync(AnyContext ctx, string moduleName, string commandName) + => default; + + /// + /// This method is called if no command was found for the input. + /// Useful if you want to have games or features which take arbitrary input + /// but ignore any messages which were blocked or caused a command execution + /// If this method was called, then will not be executed + /// Execution order: + /// + /// → + /// → + /// → + /// OR ** + /// + /// + /// A representing completion + public virtual ValueTask ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + => default; +} + +public readonly struct ExecResponse +{ +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Context/AnyContext.cs b/src/Ellie.Marmalade/Context/AnyContext.cs new file mode 100644 index 0000000..4f7207c --- /dev/null +++ b/src/Ellie.Marmalade/Context/AnyContext.cs @@ -0,0 +1,43 @@ +using Discord; +using EllieBot; + +namespace EllieBot.Marmalade; + +/// +/// Commands which take this class as a first parameter can be executed in both DMs and Servers +/// +public abstract class AnyContext +{ + /// + /// Channel from the which the command is invoked + /// + public abstract IMessageChannel Channel { get; } + + /// + /// Message which triggered the command + /// + public abstract IUserMessage Message { get; } + + /// + /// The user who invoked the command + /// + public abstract IUser User { get; } + + /// + /// Bot user + /// + public abstract ISelfUser Bot { get; } + + /// + /// Provides access to strings used by this marmalade + /// + public abstract IMarmaladeStrings Strings { get; } + + /// + /// Gets a formatted localized string using a key and arguments which should be formatted in + /// + /// The key of the string as specified in localization files + /// Arguments (if any) to format in + /// A formatted localized string + public abstract string GetText(string key, object[]? args = null); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Context/DmContext.cs b/src/Ellie.Marmalade/Context/DmContext.cs new file mode 100644 index 0000000..d971ee5 --- /dev/null +++ b/src/Ellie.Marmalade/Context/DmContext.cs @@ -0,0 +1,11 @@ +using Discord; + +namespace EllieBot.Marmalade; + +/// +/// Commands which take this type as the first parameter can only be executed in DMs +/// +public abstract class DmContext : AnyContext +{ + public abstract override IDMChannel Channel { get; } +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Context/GuildContext.cs b/src/Ellie.Marmalade/Context/GuildContext.cs new file mode 100644 index 0000000..63ca873 --- /dev/null +++ b/src/Ellie.Marmalade/Context/GuildContext.cs @@ -0,0 +1,12 @@ +using Discord; + +namespace EllieBot.Marmalade; + +/// +/// Commands which take this type as a first parameter can only be executed in a server +/// +public abstract class GuildContext : AnyContext +{ + public abstract override ITextChannel Channel { get; } + public abstract IGuild Guild { get; } +} diff --git a/src/Ellie.Marmalade/Ellie.Marmalade.csproj b/src/Ellie.Marmalade/Ellie.Marmalade.csproj new file mode 100644 index 0000000..db33025 --- /dev/null +++ b/src/Ellie.Marmalade/Ellie.Marmalade.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + The EllieBot Devs + + + + + + + + + + 9.0.0 + + + diff --git a/src/Ellie.Marmalade/EmbedColor.cs b/src/Ellie.Marmalade/EmbedColor.cs new file mode 100644 index 0000000..cd492b5 --- /dev/null +++ b/src/Ellie.Marmalade/EmbedColor.cs @@ -0,0 +1,8 @@ +namespace EllieBot; + +public enum EmbedColor +{ + Ok, + Pending, + Error +} diff --git a/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs new file mode 100644 index 0000000..1047966 --- /dev/null +++ b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs @@ -0,0 +1,61 @@ +using Discord; + +namespace EllieBot.Marmalade; + +public static class MarmaladeExtensions +{ + public static Task EmbedAsync(this IMessageChannel ch, EmbedBuilder embed, string msg = "") + => ch.SendMessageAsync(msg, + embed: embed.Build(), + options: new() + { + RetryMode = RetryMode.Retry502 + }); + + // unlocalized + public static Task SendConfirmAsync(this AnyContext ctx, string msg) + => ctx.Channel.EmbedAsync(new EmbedBuilder() + .WithColor(0, 200, 0) + .WithDescription(msg)); + + public static Task SendPendingAsync(this AnyContext ctx, string msg) + => ctx.Channel.EmbedAsync(new EmbedBuilder() + .WithColor(200, 200, 0) + .WithDescription(msg)); + + public static Task SendErrorAsync(this AnyContext ctx, string msg) + => ctx.Channel.EmbedAsync(new EmbedBuilder() + .WithColor(200, 0, 0) + .WithDescription(msg)); + + // localized + public static Task ConfirmAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("✅")); + + public static Task ErrorAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("❌")); + + public static Task WarningAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("⚠️")); + + public static Task WaitAsync(this AnyContext ctx) + => ctx.Message.AddReactionAsync(new Emoji("🤔")); + + public static Task ErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendErrorAsync(ctx.GetText(key, args)); + + public static Task PendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendPendingAsync(ctx.GetText(key, args)); + + public static Task ConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendConfirmAsync(ctx.GetText(key, args)); + + public static Task ReplyErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}"); + + public static Task ReplyPendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}"); + + public static Task ReplyConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args) + => ctx.SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}"); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/IEmbedBuilder.cs b/src/Ellie.Marmalade/IEmbedBuilder.cs new file mode 100644 index 0000000..0d77367 --- /dev/null +++ b/src/Ellie.Marmalade/IEmbedBuilder.cs @@ -0,0 +1,18 @@ +using Discord; + +namespace EllieBot; + +public interface IEmbedBuilder +{ + IEmbedBuilder WithDescription(string? desc); + IEmbedBuilder WithTitle(string? title); + IEmbedBuilder AddField(string title, object value, bool isInline = false); + IEmbedBuilder WithFooter(string text, string? iconUrl = null); + IEmbedBuilder WithAuthor(string name, string? iconUrl = null, string? url = null); + IEmbedBuilder WithColor(EmbedColor color); + IEmbedBuilder WithDiscordColor(Color color); + Embed Build(); + IEmbedBuilder WithUrl(string url); + IEmbedBuilder WithImageUrl(string url); + IEmbedBuilder WithThumbnailUrl(string url); +} diff --git a/src/Ellie.Marmalade/ParamParser/ParamParser.cs b/src/Ellie.Marmalade/ParamParser/ParamParser.cs new file mode 100644 index 0000000..e4758d6 --- /dev/null +++ b/src/Ellie.Marmalade/ParamParser/ParamParser.cs @@ -0,0 +1,16 @@ +namespace EllieBot.Marmalade; + +/// +/// Overridden to implement parsers for custom types +/// +/// Type into which to parse the input +public abstract class ParamParser +{ + /// + /// Overridden to implement parsing logic + /// + /// Context + /// Input to parse + /// A with successful or failed status + public abstract ValueTask> TryParseAsync(AnyContext ctx, string input); +} diff --git a/src/Ellie.Marmalade/ParamParser/ParseResult.cs b/src/Ellie.Marmalade/ParamParser/ParseResult.cs new file mode 100644 index 0000000..24c115b --- /dev/null +++ b/src/Ellie.Marmalade/ParamParser/ParseResult.cs @@ -0,0 +1,48 @@ +namespace EllieBot.Marmalade; + +public readonly struct ParseResult +{ + /// + /// Whether the parsing was successful + /// + public bool IsSuccess { get; private init; } + + /// + /// Parsed value. It should only have value if is set to true + /// + public T? Data { get; private init; } + + /// + /// Instantiate a **successful** parse result + /// + /// Parsed value + public ParseResult(T data) + { + Data = data; + IsSuccess = true; + } + + + /// + /// Create a new with IsSuccess = false + /// + /// A new + public static ParseResult Fail() + => new ParseResult + { + IsSuccess = false, + Data = default, + }; + + /// + /// Create a new with IsSuccess = true + /// + /// Value of the parsed object + /// A new + public static ParseResult Success(T obj) + => new ParseResult + { + IsSuccess = true, + Data = obj, + }; +} diff --git a/src/Ellie.Marmalade/README.md b/src/Ellie.Marmalade/README.md new file mode 100644 index 0000000..98e851d --- /dev/null +++ b/src/Ellie.Marmalade/README.md @@ -0,0 +1 @@ +This is the library which is the base of any marmalade. \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/CommandStrings.cs b/src/Ellie.Marmalade/Strings/CommandStrings.cs new file mode 100644 index 0000000..056d028 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/CommandStrings.cs @@ -0,0 +1,24 @@ +using YamlDotNet.Serialization; + +namespace EllieBot.Marmalade; + +public readonly struct CommandStrings +{ + public CommandStrings(string? desc, string[]? args) + { + Desc = desc; + Args = args; + } + + [YamlMember(Alias = "desc")] + public string? Desc { get; init; } + + [YamlMember(Alias = "args")] + public string[]? Args { get; init; } + + public void Deconstruct(out string? desc, out string[]? args) + { + desc = Desc; + args = Args; + } +} diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs new file mode 100644 index 0000000..f76cf82 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs @@ -0,0 +1,15 @@ +using System.Globalization; + +namespace EllieBot.Marmalade; + +/// +/// Defines methods to retrieve and reload marmalade strings +/// +public interface IMarmaladeStrings +{ + // string GetText(string key, ulong? guildId = null, params object[] data); + string? GetText(string key, CultureInfo locale, params object[] data); + void Reload(); + CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo); + string? GetDescription(CultureInfo? locale); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs new file mode 100644 index 0000000..2845f94 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs @@ -0,0 +1,28 @@ +namespace EllieBot.Marmalade; + +/// +/// Implemented by classes which provide localized strings in their own ways +/// +public interface IMarmaladeStringsProvider +{ + /// + /// Gets localized string + /// + /// Language name + /// String key + /// Localized string + string? GetText(string localeName, string key); + + /// + /// Reloads string cache + /// + void Reload(); + + // /// + // /// Gets command arg examples and description + // /// + // /// Language name + // /// Command name + // CommandStrings GetCommandStrings(string localeName, string commandName); + CommandStrings? GetCommandStrings(string localeName, string commandName); +} diff --git a/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs b/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs new file mode 100644 index 0000000..3b02ca6 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs @@ -0,0 +1,40 @@ +namespace EllieBot.Marmalade; + +public class LocalMarmaladeStringsProvider : IMarmaladeStringsProvider +{ + private readonly StringsLoader _source; + private IReadOnlyDictionary> _responseStrings; + private IReadOnlyDictionary> _commandStrings; + + public LocalMarmaladeStringsProvider(StringsLoader source) + { + _source = source; + _responseStrings = _source.GetResponseStrings(); + _commandStrings = _source.GetCommandStrings(); + } + + public void Reload() + { + _responseStrings = _source.GetResponseStrings(); + _commandStrings = _source.GetCommandStrings(); + } + + + public string? GetText(string localeName, string key) + { + if (_responseStrings.TryGetValue(localeName.ToLowerInvariant(), out var langStrings) + && langStrings.TryGetValue(key.ToLowerInvariant(), out var text)) + return text; + + return null; + } + + public CommandStrings? GetCommandStrings(string localeName, string commandName) + { + if (_commandStrings.TryGetValue(localeName.ToLowerInvariant(), out var langStrings) + && langStrings.TryGetValue(commandName.ToLowerInvariant(), out var strings)) + return strings; + + return null; + } +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs new file mode 100644 index 0000000..c4f9f26 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs @@ -0,0 +1,79 @@ +using System.Globalization; +using Serilog; + +namespace EllieBot.Marmalade; + +public class MarmaladeStrings : IMarmaladeStrings +{ + /// + /// Used as failsafe in case response key doesn't exist in the selected or default language. + /// + private readonly CultureInfo _usCultureInfo = new("en-US"); + + private readonly IMarmaladeStringsProvider _stringsProvider; + + public MarmaladeStrings(IMarmaladeStringsProvider stringsProvider) + { + _stringsProvider = stringsProvider; + } + + private string? GetString(string key, CultureInfo cultureInfo) + => _stringsProvider.GetText(cultureInfo.Name, key); + + public string? GetText(string key, CultureInfo cultureInfo) + => GetString(key, cultureInfo) + ?? GetString(key, _usCultureInfo); + + public string? GetText(string key, CultureInfo cultureInfo, params object[] data) + { + var text = GetText(key, cultureInfo); + + if (string.IsNullOrWhiteSpace(text)) + return null; + + try + { + return string.Format(text, data); + } + catch (FormatException) + { + Log.Warning(" Key '{Key}' is not properly formatted in '{LanguageName}' response strings", + key, + cultureInfo.Name); + + return $"⚠️ Response string key '{key}' is not properly formatted. Please report this.\n\n{text}"; + } + } + + public CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo) + { + var cmdStrings = _stringsProvider.GetCommandStrings(cultureInfo.Name, commandName); + if (cmdStrings is null) + { + if (cultureInfo.Name == _usCultureInfo.Name) + { + Log.Warning("'{CommandName}' doesn't exist in 'en-US' command strings for one of the marmalades", + commandName); + + return new(null, null); + } + + Log.Information("Missing '{CommandName}' command strings for the '{LocaleName}' locale", + commandName, + cultureInfo.Name); + + return GetCommandStrings(commandName, _usCultureInfo); + } + + return cmdStrings.Value; + } + + public string? GetDescription(CultureInfo? locale = null) + => GetText("marmalades.description", locale ?? _usCultureInfo); + + public static MarmaladeStrings CreateDefault(string basePath) + => new MarmaladeStrings(new LocalMarmaladeStringsProvider(new(basePath))); + + public void Reload() + => _stringsProvider.Reload(); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/Strings/StringsLoader.cs b/src/Ellie.Marmalade/Strings/StringsLoader.cs new file mode 100644 index 0000000..e9b2493 --- /dev/null +++ b/src/Ellie.Marmalade/Strings/StringsLoader.cs @@ -0,0 +1,137 @@ +using System.Diagnostics.CodeAnalysis; +using Serilog; +using YamlDotNet.Serialization; + +namespace EllieBot.Marmalade; + +/// +/// Loads strings from the shortcut or localizable path +/// +public class StringsLoader +{ + private readonly string _localizableResponsesPath; + private readonly string _shortcutResponsesFile; + + private readonly string _localizableCommandsPath; + private readonly string _shortcutCommandsFile; + + public StringsLoader(string basePath) + { + _localizableResponsesPath = Path.Join(basePath, "strings/res"); + _shortcutResponsesFile = Path.Join(basePath, "res.yml"); + + _localizableCommandsPath = Path.Join(basePath, "strings/cmds"); + _shortcutCommandsFile = Path.Join(basePath, "cmds.yml"); + } + + public IReadOnlyDictionary> GetCommandStrings() + { + var outputDict = new Dictionary>(); + + if (File.Exists(_shortcutCommandsFile)) + { + if (TryLoadCommandsFromFile(_shortcutCommandsFile, out var dict, out _)) + { + outputDict["en-us"] = dict; + } + + return outputDict; + } + + if (Directory.Exists(_localizableCommandsPath)) + { + foreach (var cmdsFile in Directory.EnumerateFiles(_localizableCommandsPath)) + { + if (TryLoadCommandsFromFile(cmdsFile, out var dict, out var locale) && locale is not null) + { + outputDict[locale.ToLowerInvariant()] = dict; + } + } + } + + return outputDict; + } + + + private static readonly IDeserializer _deserializer = new DeserializerBuilder().Build(); + private static bool TryLoadCommandsFromFile(string file, + [NotNullWhen(true)] out IReadOnlyDictionary? strings, + out string? localeName) + { + try + { + var text = File.ReadAllText(file); + strings = _deserializer.Deserialize?>(text) + ?? new(); + localeName = GetLocaleName(file); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message); + } + + strings = null; + localeName = null; + return false; + } + + + public IReadOnlyDictionary> GetResponseStrings() + { + var outputDict = new Dictionary>(); + + // try to load a shortcut file + if (File.Exists(_shortcutResponsesFile)) + { + if (TryLoadResponsesFromFile(_shortcutResponsesFile, out var dict, out _)) + { + outputDict["en-us"] = dict; + } + + return outputDict; + } + + if (!Directory.Exists(_localizableResponsesPath)) + return outputDict; + + // if shortcut file doesn't exist, try to load localizable files + foreach (var file in Directory.GetFiles(_localizableResponsesPath)) + { + if (TryLoadResponsesFromFile(file, out var strings, out var localeName) && localeName is not null) + { + outputDict[localeName.ToLowerInvariant()] = strings; + } + } + + return outputDict; + } + + private static bool TryLoadResponsesFromFile(string file, + [NotNullWhen(true)] out IReadOnlyDictionary? strings, + out string? localeName) + { + try + { + strings = _deserializer.Deserialize?>(File.ReadAllText(file)); + if (strings is null) + { + localeName = null; + return false; + } + + localeName = GetLocaleName(file).ToLowerInvariant(); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message); + strings = null; + localeName = null; + return false; + } + } + + private static string GetLocaleName(string fileName) + => Path.GetFileNameWithoutExtension(fileName); +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/pack-and-push.ps1 b/src/Ellie.Marmalade/pack-and-push.ps1 new file mode 100644 index 0000000..5f5a2eb --- /dev/null +++ b/src/Ellie.Marmalade/pack-and-push.ps1 @@ -0,0 +1,2 @@ +dotnet pack -o bin/Release/packed +dotnet nuget push bin/Release/packed/ --source emotionlab \ No newline at end of file diff --git a/src/EllieBot.Coordinator/CoordStartup.cs b/src/EllieBot.Coordinator/CoordStartup.cs new file mode 100644 index 0000000..4bc262c --- /dev/null +++ b/src/EllieBot.Coordinator/CoordStartup.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace EllieBot.Coordinator +{ + public class CoordStartup + { + public IConfiguration Configuration { get; } + + public CoordStartup(IConfiguration config) + => Configuration = config; + + public void ConfigureServices(IServiceCollection services) + { + services.AddGrpc(); + services.AddSingleton(); + services.AddSingleton( + serviceProvider => serviceProvider.GetRequiredService()); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGrpcService(); + + endpoints.MapGet("/", + async context => + { + await context.Response.WriteAsync( + "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); + }); + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj new file mode 100644 index 0000000..b4f964d --- /dev/null +++ b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + + + + + + + + + + + + + + + diff --git a/src/EllieBot.Coordinator/LogSetup.cs b/src/EllieBot.Coordinator/LogSetup.cs new file mode 100644 index 0000000..0850b39 --- /dev/null +++ b/src/EllieBot.Coordinator/LogSetup.cs @@ -0,0 +1,43 @@ +using System; +using System.Text; +using Serilog; +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; + +namespace EllieBot.Services +{ + public static class LogSetup + { + public static void SetupLogger(object source) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("System", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.File("coord.log", LogEventLevel.Information, + rollOnFileSizeLimit: true, + fileSizeLimitBytes: 10_000_000) + .WriteTo.Console(LogEventLevel.Information, + theme: GetTheme(), + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] | #{LogSource} | {Message:lj}{NewLine}{Exception}") + .Enrich.WithProperty("LogSource", source) + .CreateLogger(); + + Console.OutputEncoding = Encoding.UTF8; + } + + private static ConsoleTheme GetTheme() + { + if (Environment.OSVersion.Platform == PlatformID.Unix) + return AnsiConsoleTheme.Code; + + +#if DEBUG + return AnsiConsoleTheme.Code; +#else + return ConsoleTheme.None; +#endif + } + } +} diff --git a/src/EllieBot.Coordinator/Program.cs b/src/EllieBot.Coordinator/Program.cs new file mode 100644 index 0000000..6923013 --- /dev/null +++ b/src/EllieBot.Coordinator/Program.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using EllieBot.Coordinator; +using EllieBot.Services; +using Serilog; + +// Additional configuration is required to successfully run gRPC on macOS. +// For instructions on how to configure Kestrel and gRPC clients on macOS, visit https://go.microsoft.com/fwlink/?linkid=2099682 +static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + +LogSetup.SetupLogger("coord"); +Log.Information("Starting coordinator... Pid: {ProcessId}", Environment.ProcessId); + +CreateHostBuilder(args).Build().Run(); \ No newline at end of file diff --git a/src/EllieBot.Coordinator/Properties/launchSettings.json b/src/EllieBot.Coordinator/Properties/launchSettings.json new file mode 100644 index 0000000..1f39bc8 --- /dev/null +++ b/src/EllieBot.Coordinator/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "EllieBot.Coordinator": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": false, + "applicationUrl": "http://localhost:3442;https://localhost:3443", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/EllieBot.Coordinator/Protos/coordinator.proto b/src/EllieBot.Coordinator/Protos/coordinator.proto new file mode 100644 index 0000000..2df14c3 --- /dev/null +++ b/src/EllieBot.Coordinator/Protos/coordinator.proto @@ -0,0 +1,127 @@ +syntax = "proto3"; +import "google/protobuf/timestamp.proto"; + +option csharp_namespace = "EllieBot.Coordinator"; + +package elliebot; + +service Coordinator { + // sends update to coordinator to let it know that the shard is alive + rpc Heartbeat(HeartbeatRequest) returns (HeartbeatReply); + // restarts a shard given the id + rpc RestartShard(RestartShardRequest) returns (RestartShardReply); + // reshards given the new number of shards + rpc Reshard(ReshardRequest) returns (ReshardReply); + // Reload config + rpc Reload(ReloadRequest) returns (ReloadReply); + // Gets status of a single shard + rpc GetStatus(GetStatusRequest) returns (GetStatusReply); + // Get status of all shards + rpc GetAllStatuses(GetAllStatusesRequest) returns (GetAllStatusesReply); + // Restarts all shards. Queues them to be restarted at a normal rate. Setting Nuke to true will kill all shards right + // away + rpc RestartAllShards(RestartAllRequest) returns (RestartAllReply); + + // kill coordinator (and all shards as a consequence) + rpc Die(DieRequest) returns (DieReply); + + rpc SetConfigText(SetConfigTextRequest) returns (SetConfigTextReply); + + rpc GetConfigText(GetConfigTextRequest) returns (GetConfigTextReply); +} + +enum ConnState { + Disconnected = 0; + Connecting = 1; + Connected = 2; +} + +message HeartbeatRequest { + int32 shardId = 1; + int32 guildCount = 2; + ConnState state = 3; +} + +message HeartbeatReply { + bool gracefulImminent = 1; +} + +message RestartShardRequest { + int32 shardId = 1; + // should it be queued for restart, set false to kill it and restart immediately with priority + bool queue = 2; +} + +message RestartShardReply { + +} + +message ReshardRequest { + int32 shards = 1; +} + +message ReshardReply { + +} + +message ReloadRequest { + +} + +message ReloadReply { + +} + +message GetStatusRequest { + int32 shardId = 1; +} + +message GetStatusReply { + int32 shardId = 1; + ConnState state = 2; + int32 guildCount = 3; + google.protobuf.Timestamp lastUpdate = 4; + bool scheduledForRestart = 5; + google.protobuf.Timestamp startedAt = 6; +} + +message GetAllStatusesRequest { + +} + +message GetAllStatusesReply { + repeated GetStatusReply Statuses = 1; +} + +message RestartAllRequest { + bool nuke = 1; +} + +message RestartAllReply { + +} + +message DieRequest { + bool graceful = 1; +} + +message DieReply { + +} + +message GetConfigTextRequest { + +} + +message GetConfigTextReply { + string configYml = 1; +} + +message SetConfigTextRequest { + string configYml = 1; +} + +message SetConfigTextReply { + bool success = 1; + string error = 2; +} \ No newline at end of file diff --git a/src/EllieBot.Coordinator/README.md b/src/EllieBot.Coordinator/README.md new file mode 100644 index 0000000..9a2a993 --- /dev/null +++ b/src/EllieBot.Coordinator/README.md @@ -0,0 +1,11 @@ +# Coordinator project + +Grpc-based coordinator useful for sharded EllieBot. Its purpose is controlling the lifetime and checking status of the shards it creates. + +### Supports + +- Checking status +- Individual shard restarts +- Full shard restarts +- Graceful coordinator restarts (restart/update coordinator without killing shards) +- Kill/Stop diff --git a/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs b/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs new file mode 100644 index 0000000..780afc4 --- /dev/null +++ b/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs @@ -0,0 +1,456 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Serilog; +using YamlDotNet.Serialization; + +namespace EllieBot.Coordinator +{ + public sealed class CoordinatorRunner : BackgroundService + { + private const string CONFIG_PATH = "coord.yml"; + + private const string GRACEFUL_STATE_PATH = "graceful.json"; + private const string GRACEFUL_STATE_BACKUP_PATH = "graceful_old.json"; + + private readonly Serializer _serializer; + private readonly Deserializer _deserializer; + + private Config _config; + private ShardStatus[] _shardStatuses; + + private readonly object locker = new object(); + private readonly Random _rng; + private bool _gracefulImminent; + + public CoordinatorRunner() + { + _serializer = new(); + _deserializer = new(); + _config = LoadConfig(); + _rng = new Random(); + + if (!TryRestoreOldState()) + InitAll(); + } + + private Config LoadConfig() + { + lock (locker) + { + return _deserializer.Deserialize(File.ReadAllText(CONFIG_PATH)); + } + } + + private void SaveConfig(in Config config) + { + lock (locker) + { + var output = _serializer.Serialize(config); + File.WriteAllText(CONFIG_PATH, output); + } + } + + public void ReloadConfig() + { + lock (locker) + { + var oldConfig = _config; + var newConfig = LoadConfig(); + if (oldConfig.TotalShards != newConfig.TotalShards) + { + KillAll(); + } + _config = newConfig; + if (oldConfig.TotalShards != newConfig.TotalShards) + { + InitAll(); + } + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Log.Information("Executing"); + + bool first = true; + while (!stoppingToken.IsCancellationRequested) + { + try + { + bool hadAction = false; + lock (locker) + { + var shardIds = Enumerable.Range(0, 1) // shard 0 is always first + .Append((int)((1173494918812024863 >> 22) % _config.TotalShards)) // then ellie server shard + .Concat(Enumerable.Range(1, _config.TotalShards - 1) + .OrderBy(_ => _rng.Next())) // then all other shards in a random order + .Distinct() + .ToList(); + + if (first) + { + // Log.Information("Startup order: {StartupOrder}",string.Join(' ', shardIds)); + first = false; + } + + foreach (var shardId in shardIds) + { + if (stoppingToken.IsCancellationRequested) + break; + + var status = _shardStatuses[shardId]; + + if (status.ShouldRestart) + { + Log.Warning("Shard {ShardId} is restarting (scheduled)...", shardId); + hadAction = true; + StartShard(shardId); + break; + } + + if (DateTime.UtcNow - status.LastUpdate > + TimeSpan.FromSeconds(_config.UnresponsiveSec)) + { + Log.Warning("Shard {ShardId} is restarting (unresponsive)...", shardId); + hadAction = true; + StartShard(shardId); + break; + } + + if (status.StateCounter > 8 && status.State != ConnState.Connected) + { + Log.Warning("Shard {ShardId} is restarting (stuck)...", shardId); + hadAction = true; + StartShard(shardId); + break; + } + + try + { + if (status.Process is null or { HasExited: true }) + { + Log.Warning("Shard {ShardId} is starting (process)...", shardId); + hadAction = true; + StartShard(shardId); + break; + } + } + catch (InvalidOperationException) + { + Log.Warning("Process for shard {ShardId} is bugged... ", shardId); + hadAction = true; + StartShard(shardId); + break; + } + } + } + + if (hadAction) + { + await Task.Delay(_config.RecheckIntervalMs, stoppingToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error in coordinator: {Message}", ex.Message); + } + + await Task.Delay(5000, stoppingToken).ConfigureAwait(false); + } + } + + private void StartShard(int shardId) + { + var status = _shardStatuses[shardId]; + try + { + status.Process?.Kill(true); + } + catch + { + } + try + { + status.Process?.Dispose(); + } + catch + { + } + + var proc = StartShardProcess(shardId); + _shardStatuses[shardId] = status with + { + Process = proc, + LastUpdate = DateTime.UtcNow, + State = ConnState.Disconnected, + ShouldRestart = false, + StateCounter = 0, + }; + } + + private Process StartShardProcess(int shardId) + => Process.Start(new ProcessStartInfo() + { + FileName = _config.ShardStartCommand, + Arguments = string.Format(_config.ShardStartArgs, + shardId, + _config.TotalShards), + EnvironmentVariables = + { + {"ELLIEBOT_IS_COORDINATED", "1"} + } + // CreateNoWindow = true, + // UseShellExecute = false, + }); + + public bool Heartbeat(int shardId, int guildCount, ConnState state) + { + lock (locker) + { + if (shardId >= _shardStatuses.Length) + throw new ArgumentOutOfRangeException(nameof(shardId)); + + var status = _shardStatuses[shardId]; + status = _shardStatuses[shardId] = status with + { + GuildCount = guildCount, + State = state, + LastUpdate = DateTime.UtcNow, + StateCounter = status.State == state + ? status.StateCounter + 1 + : 1 + }; + if (status.StateCounter > 1 && status.State == ConnState.Disconnected) + { + Log.Warning("Shard {ShardId} is in DISCONNECTED state! ({StateCounter})", + status.ShardId, + status.StateCounter); + } + + return _gracefulImminent; + } + } + + public void SetShardCount(int totalShards) + { + lock (locker) + { + SaveConfig(new Config( + totalShards, + _config.RecheckIntervalMs, + _config.ShardStartCommand, + _config.ShardStartArgs, + _config.UnresponsiveSec)); + } + } + + public void RestartShard(int shardId, bool queue) + { + lock (locker) + { + if (shardId >= _shardStatuses.Length) + throw new ArgumentOutOfRangeException(nameof(shardId)); + + _shardStatuses[shardId] = _shardStatuses[shardId] with + { + ShouldRestart = true, + StateCounter = 0, + }; + } + } + + public void RestartAll(bool nuke) + { + lock (locker) + { + if (nuke) + { + KillAll(); + } + + QueueAll(); + } + } + + private void KillAll() + { + lock (locker) + { + for (var shardId = 0; shardId < _shardStatuses.Length; shardId++) + { + var status = _shardStatuses[shardId]; + if (status.Process is Process p) + { + try { p.Kill(); } catch { } + try { p.Dispose(); } catch { } + _shardStatuses[shardId] = status with + { + Process = null, + ShouldRestart = true, + LastUpdate = DateTime.UtcNow, + State = ConnState.Disconnected, + StateCounter = 0, + }; + } + } + } + } + + public void SaveState() + { + var coordState = new CoordState() + { + StatusObjects = _shardStatuses + .Select(x => new JsonStatusObject() + { + Pid = x.Process?.Id, + ConnectionState = x.State, + GuildCount = x.GuildCount, + }) + .ToList() + }; + var jsonState = JsonSerializer.Serialize(coordState, new JsonSerializerOptions() + { + WriteIndented = true, + }); + File.WriteAllText(GRACEFUL_STATE_PATH, jsonState); + } + private bool TryRestoreOldState() + { + lock (locker) + { + if (!File.Exists(GRACEFUL_STATE_PATH)) + return false; + + Log.Information("Restoring old coordinator state..."); + + CoordState savedState; + try + { + savedState = JsonSerializer.Deserialize(File.ReadAllText(GRACEFUL_STATE_PATH)); + + if (savedState is null) + throw new Exception("Old state is null?!"); + } + catch (Exception ex) + { + Log.Error(ex, "Error deserializing old state: {Message}", ex.Message); + File.Move(GRACEFUL_STATE_PATH, GRACEFUL_STATE_BACKUP_PATH, overwrite: true); + return false; + } + + if (savedState.StatusObjects.Count != _config.TotalShards) + { + Log.Error("Unable to restore old state because shard count doesn't match"); + File.Move(GRACEFUL_STATE_PATH, GRACEFUL_STATE_BACKUP_PATH, overwrite: true); + return false; + } + + _shardStatuses = new ShardStatus[_config.TotalShards]; + + for (int shardId = 0; shardId < _shardStatuses.Length; shardId++) + { + var statusObj = savedState.StatusObjects[shardId]; + Process p = null; + if (statusObj.Pid is { } pid) + { + try + { + p = Process.GetProcessById(pid); + } + catch (Exception ex) + { + Log.Warning(ex, "Process for shard {ShardId} is not runnning", shardId); + } + } + + _shardStatuses[shardId] = new( + shardId, + DateTime.UtcNow, + statusObj.GuildCount, + statusObj.ConnectionState, + p is null, + p); + } + + File.Move(GRACEFUL_STATE_PATH, GRACEFUL_STATE_BACKUP_PATH, overwrite: true); + Log.Information("Old state restored!"); + return true; + } + } + + private void InitAll() + { + lock (locker) + { + _shardStatuses = new ShardStatus[_config.TotalShards]; + for (var shardId = 0; shardId < _shardStatuses.Length; shardId++) + { + _shardStatuses[shardId] = new ShardStatus(shardId, DateTime.UtcNow); + } + } + } + + private void QueueAll() + { + lock (locker) + { + for (var shardId = 0; shardId < _shardStatuses.Length; shardId++) + { + _shardStatuses[shardId] = _shardStatuses[shardId] with + { + ShouldRestart = true + }; + } + } + } + + + public ShardStatus GetShardStatus(int shardId) + { + lock (locker) + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(shardId, _shardStatuses.Length); + + return _shardStatuses[shardId]; + } + } + + public List GetAllStatuses() + { + lock (locker) + { + var toReturn = new List(_shardStatuses.Length); + toReturn.AddRange(_shardStatuses); + return toReturn; + } + } + + public void PrepareGracefulShutdown() + { + lock (locker) + { + _gracefulImminent = true; + } + } + + public string GetConfigText() + => File.ReadAllText(CONFIG_PATH); + + public void SetConfigText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentNullException(nameof(text), "coord.yml can't be empty"); + var config = _deserializer.Deserialize(text); + SaveConfig(in config); + ReloadConfig(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Coordinator/Services/CoordinatorService.cs b/src/EllieBot.Coordinator/Services/CoordinatorService.cs new file mode 100644 index 0000000..8666736 --- /dev/null +++ b/src/EllieBot.Coordinator/Services/CoordinatorService.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading.Tasks; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; + +namespace EllieBot.Coordinator +{ + public sealed class CoordinatorService : Coordinator.CoordinatorBase + { + private readonly CoordinatorRunner _runner; + + public CoordinatorService(CoordinatorRunner runner) + => _runner = runner; + + public override Task Heartbeat(HeartbeatRequest request, ServerCallContext context) + { + var gracefulImminent = _runner.Heartbeat(request.ShardId, request.GuildCount, request.State); + return Task.FromResult(new HeartbeatReply() + { + GracefulImminent = gracefulImminent + }); + } + + public override Task Reshard(ReshardRequest request, ServerCallContext context) + { + _runner.SetShardCount(request.Shards); + return Task.FromResult(new ReshardReply()); + } + + public override Task RestartShard(RestartShardRequest request, ServerCallContext context) + { + _runner.RestartShard(request.ShardId, request.Queue); + return Task.FromResult(new RestartShardReply()); + } + + public override Task Reload(ReloadRequest request, ServerCallContext context) + { + _runner.ReloadConfig(); + return Task.FromResult(new ReloadReply()); + } + + public override Task GetStatus(GetStatusRequest request, ServerCallContext context) + { + var status = _runner.GetShardStatus(request.ShardId); + + + return Task.FromResult(StatusToStatusReply(status)); + } + + public override Task GetAllStatuses(GetAllStatusesRequest request, + ServerCallContext context) + { + var statuses = _runner + .GetAllStatuses(); + + var reply = new GetAllStatusesReply(); + foreach (var status in statuses) + reply.Statuses.Add(StatusToStatusReply(status)); + + return Task.FromResult(reply); + } + + private static GetStatusReply StatusToStatusReply(ShardStatus status) + { + DateTime startTime; + try + { + startTime = status.Process is null or { HasExited: true } + ? DateTime.MinValue.ToUniversalTime() + : status.Process.StartTime.ToUniversalTime(); + } + catch + { + startTime = DateTime.MinValue.ToUniversalTime(); + } + + var reply = new GetStatusReply() + { + State = status.State, + GuildCount = status.GuildCount, + ShardId = status.ShardId, + LastUpdate = Timestamp.FromDateTime(status.LastUpdate), + ScheduledForRestart = status.ShouldRestart, + StartedAt = Timestamp.FromDateTime(startTime) + }; + + return reply; + } + + public override Task RestartAllShards(RestartAllRequest request, ServerCallContext context) + { + _runner.RestartAll(request.Nuke); + return Task.FromResult(new RestartAllReply()); + } + + public override async Task Die(DieRequest request, ServerCallContext context) + { + if (request.Graceful) + { + _runner.PrepareGracefulShutdown(); + await Task.Delay(10_000); + } + + _runner.SaveState(); + _ = Task.Run(async () => + { + await Task.Delay(250); + Environment.Exit(0); + }); + + return new DieReply(); + } + + public override Task SetConfigText(SetConfigTextRequest request, ServerCallContext context) + { + var error = string.Empty; + var success = true; + try + { + _runner.SetConfigText(request.ConfigYml); + } + catch (Exception ex) + { + error = ex.Message; + success = false; + } + + return Task.FromResult(new(new() + { + Success = success, + Error = error + })); + } + + public override Task GetConfigText(GetConfigTextRequest request, ServerCallContext context) + { + var text = _runner.GetConfigText(); + return Task.FromResult(new GetConfigTextReply() + { + ConfigYml = text, + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Coordinator/Shared/Config.cs b/src/EllieBot.Coordinator/Shared/Config.cs new file mode 100644 index 0000000..af22c33 --- /dev/null +++ b/src/EllieBot.Coordinator/Shared/Config.cs @@ -0,0 +1,21 @@ +namespace EllieBot.Coordinator +{ + public readonly struct Config + { + public int TotalShards { get; init; } + public int RecheckIntervalMs { get; init; } + public string ShardStartCommand { get; init; } + public string ShardStartArgs { get; init; } + public double UnresponsiveSec { get; init; } + + public Config(int totalShards, int recheckIntervalMs, string shardStartCommand, string shardStartArgs, double unresponsiveSec) + { + TotalShards = totalShards; + RecheckIntervalMs = recheckIntervalMs; + ShardStartCommand = shardStartCommand; + ShardStartArgs = shardStartArgs; + UnresponsiveSec = unresponsiveSec; + } + + } +} \ No newline at end of file diff --git a/src/EllieBot.Coordinator/Shared/CoordState.cs b/src/EllieBot.Coordinator/Shared/CoordState.cs new file mode 100644 index 0000000..c9dfef8 --- /dev/null +++ b/src/EllieBot.Coordinator/Shared/CoordState.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace EllieBot.Coordinator +{ + public class CoordState + { + public List StatusObjects { get; init; } + } +} \ No newline at end of file diff --git a/src/EllieBot.Coordinator/Shared/JsonStatusObject.cs b/src/EllieBot.Coordinator/Shared/JsonStatusObject.cs new file mode 100644 index 0000000..5de659f --- /dev/null +++ b/src/EllieBot.Coordinator/Shared/JsonStatusObject.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Coordinator +{ + public class JsonStatusObject + { + public int? Pid { get; init; } + public int GuildCount { get; init; } + public ConnState ConnectionState { get; init; } + } +} \ No newline at end of file diff --git a/src/EllieBot.Coordinator/Shared/ShardStatus.cs b/src/EllieBot.Coordinator/Shared/ShardStatus.cs new file mode 100644 index 0000000..d2deb0b --- /dev/null +++ b/src/EllieBot.Coordinator/Shared/ShardStatus.cs @@ -0,0 +1,15 @@ +using System; +using System.Diagnostics; + +namespace EllieBot.Coordinator +{ + public sealed record ShardStatus( + int ShardId, + DateTime LastUpdate, + int GuildCount = 0, + ConnState State = ConnState.Disconnected, + bool ShouldRestart = false, + Process Process = null, + int StateCounter = 0 + ); +} \ No newline at end of file diff --git a/src/EllieBot.Coordinator/appsettings.Development.json b/src/EllieBot.Coordinator/appsettings.Development.json new file mode 100644 index 0000000..8983e0f --- /dev/null +++ b/src/EllieBot.Coordinator/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/EllieBot.Coordinator/appsettings.json b/src/EllieBot.Coordinator/appsettings.json new file mode 100644 index 0000000..7e5ece7 --- /dev/null +++ b/src/EllieBot.Coordinator/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + }, + "Endpoints": { + "Http": { + "Url": "http://localhost:3442" + } + } + } +} diff --git a/src/EllieBot.Coordinator/coord.yml b/src/EllieBot.Coordinator/coord.yml new file mode 100644 index 0000000..02587a2 --- /dev/null +++ b/src/EllieBot.Coordinator/coord.yml @@ -0,0 +1,12 @@ +# total number of shards +TotalShards: 3 +# How often do shards ping their state back to the coordinator +RecheckIntervalMs: 5000 +# Command to run the shard +ShardStartCommand: dotnet +# Arguments to run the shard +# {0} = shard id +# {1} = total number of shards +ShardStartArgs: run -p "..\EllieBot\EllieBot.csproj" --no-build -- {0} {1} +# How long does it take for the shard to be forcefully restarted once it stops reporting its state +UnresponsiveSec: 30 diff --git a/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs b/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs new file mode 100644 index 0000000..4b5f8cb --- /dev/null +++ b/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs @@ -0,0 +1,258 @@ +// Code temporarily yeeted from +// https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs +// because of NRT issue +#nullable enable +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Text; + +namespace Cloneable +{ + [Generator] + public class CloneableGenerator : ISourceGenerator + { + private const string PREVENT_DEEP_COPY_KEY_STRING = "PreventDeepCopy"; + private const string EXPLICIT_DECLARATION_KEY_STRING = "ExplicitDeclaration"; + + private const string CLONEABLE_NAMESPACE = "Cloneable"; + private const string CLONEABLE_ATTRIBUTE_STRING = "CloneableAttribute"; + private const string CLONE_ATTRIBUTE_STRING = "CloneAttribute"; + private const string IGNORE_CLONE_ATTRIBUTE_STRING = "IgnoreCloneAttribute"; + + private const string CLONEABLE_ATTRIBUTE_TEXT = $$""" + // + using System; + + namespace {{CLONEABLE_NAMESPACE}} + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = true, AllowMultiple = false)] + internal sealed class {{CLONEABLE_ATTRIBUTE_STRING}} : Attribute + { + public {{CLONEABLE_ATTRIBUTE_STRING}}() + { + } + + public bool {{EXPLICIT_DECLARATION_KEY_STRING}} { get; set; } + } + } + + """; + + private const string CLONE_PROPERTY_ATTRIBUTE_TEXT = $$""" + // + using System; + + namespace {{CLONEABLE_NAMESPACE}} + { + [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + internal sealed class {{CLONE_ATTRIBUTE_STRING}} : Attribute + { + public {{CLONE_ATTRIBUTE_STRING}}() + { + } + + public bool {{PREVENT_DEEP_COPY_KEY_STRING}} { get; set; } + } + } + + """; + + private const string IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT = $$""" + // + using System; + + namespace {{CLONEABLE_NAMESPACE}} + { + [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + internal sealed class {{IGNORE_CLONE_ATTRIBUTE_STRING}} : Attribute + { + public {{IGNORE_CLONE_ATTRIBUTE_STRING}}() + { + } + } + } + + """; + + private INamedTypeSymbol? _cloneableAttribute; + private INamedTypeSymbol? _ignoreCloneAttribute; + private INamedTypeSymbol? _cloneAttribute; + + public void Initialize(GeneratorInitializationContext context) + => context.RegisterForSyntaxNotifications(() => new SyntaxReceiver()); + + public void Execute(GeneratorExecutionContext context) + { + InjectCloneableAttributes(context); + GenerateCloneMethods(context); + } + + private void GenerateCloneMethods(GeneratorExecutionContext context) + { + if (context.SyntaxReceiver is not SyntaxReceiver receiver) + return; + + Compilation compilation = GetCompilation(context); + + InitAttributes(compilation); + + var classSymbols = GetClassSymbols(compilation, receiver); + foreach (var classSymbol in classSymbols) + { + if (!classSymbol.TryGetAttribute(_cloneableAttribute!, out var attributes)) + continue; + + var attribute = attributes.Single(); + var isExplicit = (bool?)attribute.NamedArguments.FirstOrDefault(e => e.Key.Equals(EXPLICIT_DECLARATION_KEY_STRING)).Value.Value ?? false; + context.AddSource($"{classSymbol.Name}_cloneable.g.cs", SourceText.From(CreateCloneableCode(classSymbol, isExplicit), Encoding.UTF8)); + } + } + + private void InitAttributes(Compilation compilation) + { + _cloneableAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{CLONEABLE_ATTRIBUTE_STRING}")!; + _cloneAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{CLONE_ATTRIBUTE_STRING}")!; + _ignoreCloneAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{IGNORE_CLONE_ATTRIBUTE_STRING}")!; + } + + private static Compilation GetCompilation(GeneratorExecutionContext context) + { + var options = context.Compilation.SyntaxTrees.First().Options as CSharpParseOptions; + + var compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(CLONEABLE_ATTRIBUTE_TEXT, Encoding.UTF8), options)). + AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8), options)). + AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8), options)); + return compilation; + } + + private string CreateCloneableCode(INamedTypeSymbol classSymbol, bool isExplicit) + { + string namespaceName = classSymbol.ContainingNamespace.ToDisplayString(); + var fieldAssignmentsCode = GenerateFieldAssignmentsCode(classSymbol, isExplicit).ToList(); + var fieldAssignmentsCodeSafe = fieldAssignmentsCode.Select(x => + { + if (x.isCloneable) + return x.line + "Safe(referenceChain)"; + return x.line; + }); + var fieldAssignmentsCodeFast = fieldAssignmentsCode.Select(x => + { + if (x.isCloneable) + return x.line + "()"; + return x.line; + }); + + return $@"using System.Collections.Generic; + +namespace {namespaceName} +{{ + {GetAccessModifier(classSymbol)} partial class {classSymbol.Name} + {{ + /// + /// Creates a copy of {classSymbol.Name} with NO circular reference checking. This method should be used if performance matters. + /// + /// Will occur on any object that has circular references in the hierarchy. + /// + public {classSymbol.Name} Clone() + {{ + return new {classSymbol.Name} + {{ +{string.Join(",\n", fieldAssignmentsCodeFast)} + }}; + }} + + /// + /// Creates a copy of {classSymbol.Name} with circular reference checking. If a circular reference was detected, only a reference of the leaf object is passed instead of cloning it. + /// + /// Should only be provided if specific objects should not be cloned but passed by reference instead. + public {classSymbol.Name} CloneSafe(Stack referenceChain = null) + {{ + if(referenceChain?.Contains(this) == true) + return this; + referenceChain ??= new Stack(); + 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 GetCloneableProperties(ITypeSymbol classSymbol, bool isExplicit) + { + var targetSymbolMembers = classSymbol.GetMembers().OfType() + .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 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)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Generators/Cloneable/SymbolExtensions.cs b/src/EllieBot.Generators/Cloneable/SymbolExtensions.cs new file mode 100644 index 0000000..8d6de76 --- /dev/null +++ b/src/EllieBot.Generators/Cloneable/SymbolExtensions.cs @@ -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 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)); + } +} \ No newline at end of file diff --git a/src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs b/src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs new file mode 100644 index 0000000..ae0d029 --- /dev/null +++ b/src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs @@ -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 CandidateClasses { get; } = new List(); + + /// + /// Called for every syntax node in the compilation, we can inspect the nodes and save any information useful for generation + /// + 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); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Generators/EllieBot.Generators.csproj b/src/EllieBot.Generators/EllieBot.Generators.csproj new file mode 100644 index 0000000..1dbbc1d --- /dev/null +++ b/src/EllieBot.Generators/EllieBot.Generators.csproj @@ -0,0 +1,26 @@ + + + + netstandard2.0 + latest + false + true + true + + + + + + + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + diff --git a/src/EllieBot.Generators/LocalizedStringsGenerator.cs b/src/EllieBot.Generators/LocalizedStringsGenerator.cs new file mode 100644 index 0000000..72d5040 --- /dev/null +++ b/src/EllieBot.Generators/LocalizedStringsGenerator.cs @@ -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(10); + foreach (var field in fields) + { + var matches = Regex.Matches(field.Value, @"{(?\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 GetFields(string? dataText) + { + if (string.IsNullOrWhiteSpace(dataText)) + return new(); + + Dictionary data; + try + { + var output = JsonConvert.DeserializeObject>(dataText!); + if (output is null) + return new(); + + data = output; + } + catch + { + Debug.WriteLine("Failed parsing responses file."); + return new(); + } + + var list = new List(); + foreach (var entry in data) + { + list.Add(new( + entry.Key, + entry.Value + )); + } + + return list; + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Generators/README.md b/src/EllieBot.Generators/README.md new file mode 100644 index 0000000..cfe1ea4 --- /dev/null +++ b/src/EllieBot.Generators/README.md @@ -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" + diff --git a/src/EllieBot.Tests/BotStringsTests.cs b/src/EllieBot.Tests/BotStringsTests.cs new file mode 100644 index 0000000..3432ac3 --- /dev/null +++ b/src/EllieBot.Tests/BotStringsTests.cs @@ -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(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"); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Tests/ConcurrentHashSetTests.cs b/src/EllieBot.Tests/ConcurrentHashSetTests.cs new file mode 100644 index 0000000..a6fb963 --- /dev/null +++ b/src/EllieBot.Tests/ConcurrentHashSetTests.cs @@ -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))); + } +} \ No newline at end of file diff --git a/src/EllieBot.Tests/EllieBot.Tests.csproj b/src/EllieBot.Tests/EllieBot.Tests.csproj new file mode 100644 index 0000000..d1d7845 --- /dev/null +++ b/src/EllieBot.Tests/EllieBot.Tests.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + false + + + + + + + + + + + + + diff --git a/src/EllieBot.Tests/GroupGreetTests.cs b/src/EllieBot.Tests/GroupGreetTests.cs new file mode 100644 index 0000000..ba2bf37 --- /dev/null +++ b/src/EllieBot.Tests/GroupGreetTests.cs @@ -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 _grouper; + + [SetUp] + public void Setup() + => _grouper = new GreetGrouper(); + + [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}"); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Tests/IndexedCollectionTests.cs b/src/EllieBot.Tests/IndexedCollectionTests.cs new file mode 100644 index 0000000..587f2c4 --- /dev/null +++ b/src/EllieBot.Tests/IndexedCollectionTests.cs @@ -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()); + + // 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(); + + 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(); + + // 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(() => collection.RemoveAt(999), $"No exception thrown when removing from index 999 in a collection of size {collection.Count}."); + Assert.Throws(() => collection.RemoveAt(-3), $"No exception thrown when removing from negative index -3."); + } + + [Test] + public void ClearTest() + { + var collection = GetCollectionSample(); + collection.Clear(); + + Assert.IsTrue(collection.Count == 0, "Collection has not been cleared."); + Assert.Throws(() => + { + _ = collection[0]; + }, "Collection has not been cleared."); + } + + [Test] + public void CopyToTest() + { + var collection = GetCollectionSample(); + 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(() => collection.CopyTo(new ShopEntry[10], 4)); + Assert.Throws(() => collection.CopyTo(new ShopEntry[6], 0)); + } + + [Test] + public void IndexOfTest() + { + var collection = GetCollectionSample(); + + 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(); + + // 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(() => collection.Insert(999, new ShopEntry() { Id = 999 }), $"No exception thrown when inserting at index 999 in a collection of size {collection.Count}."); + Assert.Throws(() => 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(); + var enumerator = collection.GetEnumerator(); + + foreach (var item in collection) + { + enumerator.MoveNext(); + Assert.AreEqual(item, enumerator.Current); + } + } + + [Test] + public void IndexTest() + { + var collection = GetCollectionSample(); + + 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); + } + + /// + /// Checks whether all indices of the items are properly ordered. + /// + /// An indexed, reference type. + /// The indexed collection to be checked. + private void CheckIndices(IndexedCollection collection) where T : class, IIndexed + { + for (var index = 0; index < collection.Count; index++) + Assert.AreEqual(index, collection[index].Index); + } + + /// + /// Gets an from the specified or a collection with 10 shop entries if none is provided. + /// + /// An indexed, database entity type. + /// A sample collection to be added as an indexed collection. + /// An indexed collection of . + private IndexedCollection GetCollectionSample(IEnumerable sample = default) where T : DbEntity, IIndexed, new() + => new IndexedCollection(sample ?? Enumerable.Range(0, 10).Select(x => new T() { Id = x })); + } +} \ No newline at end of file diff --git a/src/EllieBot.Tests/KwumTests.cs b/src/EllieBot.Tests/KwumTests.cs new file mode 100644 index 0000000..21f0223 --- /dev/null +++ b/src/EllieBot.Tests/KwumTests.cs @@ -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()); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Tests/NewDeckTests.cs b/src/EllieBot.Tests/NewDeckTests.cs new file mode 100644 index 0000000..24d31d0 --- /dev/null +++ b/src/EllieBot.Tests/NewDeckTests.cs @@ -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); + } +} \ No newline at end of file diff --git a/src/EllieBot.Tests/PubSubTests.cs b/src/EllieBot.Tests/PubSubTests.cs new file mode 100644 index 0000000..81b89bc --- /dev/null +++ b/src/EllieBot.Tests/PubSubTests.cs @@ -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 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 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 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 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 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 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."); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Tests/README.md b/src/EllieBot.Tests/README.md new file mode 100644 index 0000000..738a879 --- /dev/null +++ b/src/EllieBot.Tests/README.md @@ -0,0 +1 @@ +Project which contains tests. Self explanatory \ No newline at end of file diff --git a/src/EllieBot.Tests/Random.cs b/src/EllieBot.Tests/Random.cs new file mode 100644 index 0000000..b124e0c --- /dev/null +++ b/src/EllieBot.Tests/Random.cs @@ -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); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/CloseCodes.cs b/src/EllieBot.Voice/CloseCodes.cs new file mode 100644 index 0000000..6102a19 --- /dev/null +++ b/src/EllieBot.Voice/CloseCodes.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Ayu.Discord.Gateway +{ + public static class CloseCodes + { + private static IReadOnlyDictionary _closeCodes = new ReadOnlyDictionary( + new Dictionary() + { + { 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()); + } + } +} \ No newline at end of file diff --git a/src/ayu/Ayu.Discord.Voice/Ayu.Discord.Voice.csproj b/src/EllieBot.Voice/EllieBot.Voice.csproj similarity index 68% rename from src/ayu/Ayu.Discord.Voice/Ayu.Discord.Voice.csproj rename to src/EllieBot.Voice/EllieBot.Voice.csproj index 4b51f13..b8d42d2 100644 --- a/src/ayu/Ayu.Discord.Voice/Ayu.Discord.Voice.csproj +++ b/src/EllieBot.Voice/EllieBot.Voice.csproj @@ -1,17 +1,15 @@ - - + netstandard2.1 9.0 true CS8632 1.0.2 + EllieBot.Voice - - - + + - diff --git a/src/ayu/Ayu.Discord.Voice/LibOpus.cs b/src/EllieBot.Voice/LibOpus.cs similarity index 89% rename from src/ayu/Ayu.Discord.Voice/LibOpus.cs rename to src/EllieBot.Voice/LibOpus.cs index 66d5d04..a43a9d8 100644 --- a/src/ayu/Ayu.Discord.Voice/LibOpus.cs +++ b/src/EllieBot.Voice/LibOpus.cs @@ -1,11 +1,11 @@ using System; using System.Runtime.InteropServices; -namespace Ayu.Discord.Voice +namespace EllieBot.Voice { internal static unsafe class LibOpus { - public const string OPUS = "opus"; + public const string OPUS = "data/lib/opus"; [DllImport(OPUS, EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)] internal static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error); @@ -53,11 +53,11 @@ namespace Ayu.Discord.Voice _frameDelay = frameDelay; _frameSizePerChannel = _sampleRate * _frameDelay / 1000; - _encoderPtr = LibOpus.CreateEncoder(sampleRate, channels, (int) OpusApplication.Audio, out var error); + _encoderPtr = LibOpus.CreateEncoder(sampleRate, channels, (int)OpusApplication.Audio, out var error); if (error != OpusError.OK) throw new ExternalException(error.ToString()); - LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetSignal, (int) OpusSignal.Music); + LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetSignal, (int)OpusSignal.Music); LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetInbandFEC, 1); LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetBitrate, bitRate); LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetPacketLossPerc, 2); @@ -69,15 +69,15 @@ namespace Ayu.Discord.Voice public int Encode(Span input, byte[] output) { fixed (byte* inPtr = input) - fixed (byte* outPtr = output) - return LibOpus.Encode(_encoderPtr, inPtr, FrameSizePerChannel, outPtr, output.Length); + fixed (byte* outPtr = output) + return LibOpus.Encode(_encoderPtr, inPtr, FrameSizePerChannel, outPtr, output.Length); } public int EncodeFloat(Span input, byte[] output) { fixed (byte* inPtr = input) - fixed (byte* outPtr = output) - return LibOpus.EncodeFloat(_encoderPtr, inPtr, FrameSizePerChannel, outPtr, output.Length); + fixed (byte* outPtr = output) + return LibOpus.EncodeFloat(_encoderPtr, inPtr, FrameSizePerChannel, outPtr, output.Length); } diff --git a/src/ayu/Ayu.Discord.Voice/LibSodium.cs b/src/EllieBot.Voice/LibSodium.cs similarity index 61% rename from src/ayu/Ayu.Discord.Voice/LibSodium.cs rename to src/EllieBot.Voice/LibSodium.cs index baa081e..bbbc77d 100644 --- a/src/ayu/Ayu.Discord.Voice/LibSodium.cs +++ b/src/EllieBot.Voice/LibSodium.cs @@ -1,11 +1,11 @@ using System; using System.Runtime.InteropServices; -namespace Ayu.Discord.Voice +namespace EllieBot.Voice { internal static unsafe class Sodium { - private const string SODIUM = "libsodium"; + private const string SODIUM = "data/lib/libsodium"; [DllImport(SODIUM, EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)] private static extern int SecretBoxEasy(byte* output, byte* input, long inputLength, byte* nonce, byte* secret); @@ -15,18 +15,18 @@ namespace Ayu.Discord.Voice public static int Encrypt(byte[] input, int inputOffset, long inputLength, byte[] output, int outputOffset, in ReadOnlySpan nonce, byte[] secret) { fixed (byte* inPtr = input) - fixed (byte* outPtr = output) - fixed (byte* noncePtr = nonce) - fixed (byte* secretPtr = secret) - return SecretBoxEasy(outPtr + outputOffset, inPtr + inputOffset, inputLength - inputOffset, noncePtr, secretPtr); + fixed (byte* outPtr = output) + fixed (byte* noncePtr = nonce) + fixed (byte* secretPtr = secret) + return SecretBoxEasy(outPtr + outputOffset, inPtr + inputOffset, inputLength - inputOffset, noncePtr, secretPtr); } public static int Decrypt(byte[] input, ulong inputLength, byte[] output, in ReadOnlySpan nonce, byte[] secret) { fixed (byte* outPtr = output) - fixed (byte* inPtr = input) - fixed (byte* noncePtr = nonce) - fixed (byte* secretPtr = secret) - return SecretBoxOpenEasy(outPtr, inPtr, inputLength, noncePtr, secretPtr); + fixed (byte* inPtr = input) + fixed (byte* noncePtr = nonce) + fixed (byte* secretPtr = secret) + return SecretBoxOpenEasy(outPtr, inPtr, inputLength, noncePtr, secretPtr); } } -} +} \ No newline at end of file diff --git a/src/ayu/Ayu.Discord.Voice/Models/SelectProtocol.cs b/src/EllieBot.Voice/Models/SelectProtocol.cs similarity index 89% rename from src/ayu/Ayu.Discord.Voice/Models/SelectProtocol.cs rename to src/EllieBot.Voice/Models/SelectProtocol.cs index d3ffd25..1a9dfa9 100644 --- a/src/ayu/Ayu.Discord.Voice/Models/SelectProtocol.cs +++ b/src/EllieBot.Voice/Models/SelectProtocol.cs @@ -1,6 +1,6 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; -namespace Ayu.Discord.Voice.Models +namespace EllieBot.Voice.Models { public sealed class SelectProtocol { diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceHello.cs b/src/EllieBot.Voice/Models/VoiceHello.cs similarity index 82% rename from src/ayu/Ayu.Discord.Voice/Models/VoiceHello.cs rename to src/EllieBot.Voice/Models/VoiceHello.cs index 64e8154..8fda1d1 100644 --- a/src/ayu/Ayu.Discord.Voice/Models/VoiceHello.cs +++ b/src/EllieBot.Voice/Models/VoiceHello.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace Ayu.Discord.Voice.Models +namespace EllieBot.Voice.Models { public sealed class VoiceHello { diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceIdentify.cs b/src/EllieBot.Voice/Models/VoiceIdentify.cs similarity index 91% rename from src/ayu/Ayu.Discord.Voice/Models/VoiceIdentify.cs rename to src/EllieBot.Voice/Models/VoiceIdentify.cs index be72f48..9841869 100644 --- a/src/ayu/Ayu.Discord.Voice/Models/VoiceIdentify.cs +++ b/src/EllieBot.Voice/Models/VoiceIdentify.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace Ayu.Discord.Voice.Models +namespace EllieBot.Voice.Models { public sealed class VoiceIdentify { diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoicePayload.cs b/src/EllieBot.Voice/Models/VoicePayload.cs similarity index 100% rename from src/ayu/Ayu.Discord.Voice/Models/VoicePayload.cs rename to src/EllieBot.Voice/Models/VoicePayload.cs diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceReady.cs b/src/EllieBot.Voice/Models/VoiceReady.cs similarity index 92% rename from src/ayu/Ayu.Discord.Voice/Models/VoiceReady.cs rename to src/EllieBot.Voice/Models/VoiceReady.cs index 8cae4bb..99cc753 100644 --- a/src/ayu/Ayu.Discord.Voice/Models/VoiceReady.cs +++ b/src/EllieBot.Voice/Models/VoiceReady.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace Ayu.Discord.Voice.Models +namespace EllieBot.Voice.Models { public sealed class VoiceReady { diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceResume.cs b/src/EllieBot.Voice/Models/VoiceResume.cs similarity index 89% rename from src/ayu/Ayu.Discord.Voice/Models/VoiceResume.cs rename to src/EllieBot.Voice/Models/VoiceResume.cs index b5875b8..bad82b2 100644 --- a/src/ayu/Ayu.Discord.Voice/Models/VoiceResume.cs +++ b/src/EllieBot.Voice/Models/VoiceResume.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace Ayu.Discord.Voice.Models +namespace EllieBot.Voice.Models { public sealed class VoiceResume { diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceSessionDescription.cs b/src/EllieBot.Voice/Models/VoiceSessionDescription.cs similarity index 87% rename from src/ayu/Ayu.Discord.Voice/Models/VoiceSessionDescription.cs rename to src/EllieBot.Voice/Models/VoiceSessionDescription.cs index ca02029..85bdc5e 100644 --- a/src/ayu/Ayu.Discord.Voice/Models/VoiceSessionDescription.cs +++ b/src/EllieBot.Voice/Models/VoiceSessionDescription.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace Ayu.Discord.Voice.Models +namespace EllieBot.Voice.Models { public sealed class VoiceSessionDescription { diff --git a/src/ayu/Ayu.Discord.Voice/Models/VoiceSpeaking.cs b/src/EllieBot.Voice/Models/VoiceSpeaking.cs similarity index 93% rename from src/ayu/Ayu.Discord.Voice/Models/VoiceSpeaking.cs rename to src/EllieBot.Voice/Models/VoiceSpeaking.cs index 909c10d..a9a0610 100644 --- a/src/ayu/Ayu.Discord.Voice/Models/VoiceSpeaking.cs +++ b/src/EllieBot.Voice/Models/VoiceSpeaking.cs @@ -1,7 +1,7 @@ using Newtonsoft.Json; using System; -namespace Ayu.Discord.Voice.Models +namespace EllieBot.Voice.Models { public sealed class VoiceSpeaking { diff --git a/src/ayu/Ayu.Discord.Voice/PoopyBufferImmortalized.cs b/src/EllieBot.Voice/PoopyBufferImmortalized.cs similarity index 94% rename from src/ayu/Ayu.Discord.Voice/PoopyBufferImmortalized.cs rename to src/EllieBot.Voice/PoopyBufferImmortalized.cs index 581d5fa..4a80c86 100644 --- a/src/ayu/Ayu.Discord.Voice/PoopyBufferImmortalized.cs +++ b/src/EllieBot.Voice/PoopyBufferImmortalized.cs @@ -4,7 +4,7 @@ using System.Buffers; using System.Threading; using System.Threading.Tasks; -namespace Ayu.Discord.Voice +namespace EllieBot.Voice { public sealed class PoopyBufferImmortalized : ISongBuffer { @@ -18,7 +18,7 @@ namespace Ayu.Discord.Voice public int ContentLength => WritePosition >= ReadPosition ? WritePosition - ReadPosition - : (_buffer.Length - ReadPosition) + WritePosition; + : (_buffer.Length - ReadPosition) + WritePosition; public int FreeSpace => _buffer.Length - ContentLength; @@ -104,7 +104,7 @@ namespace Ayu.Discord.Voice // writer never writes until the end, // but leaves a single chunk free Span toReturn = _outputArray; - ((Span) _buffer).Slice(ReadPosition, toRead).CopyTo(toReturn); + ((Span)_buffer).Slice(ReadPosition, toRead).CopyTo(toReturn); ReadPosition += toRead; length = toRead; return toReturn; @@ -113,7 +113,7 @@ namespace Ayu.Discord.Voice { Span toReturn = _outputArray; var toEnd = _buffer.Length - ReadPosition; - var bufferSpan = (Span) _buffer; + var bufferSpan = (Span)_buffer; bufferSpan.Slice(ReadPosition, toEnd).CopyTo(toReturn); var fromStart = toRead - toEnd; diff --git a/src/ayu/Ayu.Discord.Voice/SocketClient.cs b/src/EllieBot.Voice/SocketClient.cs similarity index 98% rename from src/ayu/Ayu.Discord.Voice/SocketClient.cs rename to src/EllieBot.Voice/SocketClient.cs index a7d4620..7c32af1 100644 --- a/src/ayu/Ayu.Discord.Voice/SocketClient.cs +++ b/src/EllieBot.Voice/SocketClient.cs @@ -33,7 +33,7 @@ namespace Ayu.Discord.Gateway bufferWriter.Advance(result.Count); if (result.MessageType == WebSocketMessageType.Close) { - var closeMessage = CloseCodes.GetErrorCodeMessage((int?) _ws.CloseStatus ?? 0).Message; + var closeMessage = CloseCodes.GetErrorCodeMessage((int?)_ws.CloseStatus ?? 0).Message; error = $"Websocket closed ({_ws.CloseStatus}): {_ws.CloseStatusDescription} {closeMessage}"; break; } @@ -130,7 +130,7 @@ namespace Ayu.Discord.Gateway await _ws.CloseAsync(WebSocketCloseStatus.InternalServerError, msg, CancellationToken.None) .ConfigureAwait(false); -return true; + return true; } catch { diff --git a/src/ayu/Ayu.Discord.Voice/SongBuffer.cs b/src/EllieBot.Voice/SongBuffer.cs similarity index 99% rename from src/ayu/Ayu.Discord.Voice/SongBuffer.cs rename to src/EllieBot.Voice/SongBuffer.cs index b5f09ef..73f2bd1 100644 --- a/src/ayu/Ayu.Discord.Voice/SongBuffer.cs +++ b/src/EllieBot.Voice/SongBuffer.cs @@ -4,7 +4,7 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -namespace Ayu.Discord.Voice +namespace EllieBot.Voice { public interface ISongBuffer : IDisposable { diff --git a/src/ayu/Ayu.Discord.Voice/VoiceClient.cs b/src/EllieBot.Voice/VoiceClient.cs similarity index 92% rename from src/ayu/Ayu.Discord.Voice/VoiceClient.cs rename to src/EllieBot.Voice/VoiceClient.cs index 31cfd5f..12d6e20 100644 --- a/src/ayu/Ayu.Discord.Voice/VoiceClient.cs +++ b/src/EllieBot.Voice/VoiceClient.cs @@ -1,7 +1,7 @@ using System; using System.Buffers; -namespace Ayu.Discord.Voice +namespace EllieBot.Voice { public sealed class VoiceClient : IDisposable { @@ -31,11 +31,11 @@ namespace Ayu.Discord.Voice FrameDelay frameDelay = FrameDelay.Delay20, BitDepthEnum bitDepthEnum = BitDepthEnum.Float32) { - this.frameDelay = (int) frameDelay; - this.sampleRate = (int) sampleRate; - this.bitRate = (int) bitRate; - this.channels = (int) channels; - this.bitDepth = (int) bitDepthEnum; + this.frameDelay = (int)frameDelay; + this.sampleRate = (int)sampleRate; + this.bitRate = (int)bitRate; + this.channels = (int)channels; + this.bitDepth = (int)bitDepthEnum; this.Encoder = new(this.sampleRate, this.channels, this.bitRate, this.frameDelay); @@ -63,7 +63,7 @@ namespace Ayu.Discord.Voice var secretKey = gw.SecretKey; if (secretKey.Length == 0) { - return (int) SendPcmError.SecretKeyUnavailable; + return (int)SendPcmError.SecretKeyUnavailable; } // encode using opus @@ -75,7 +75,7 @@ namespace Ayu.Discord.Voice } finally { - _arrayPool.Return(encodeOutput); + _arrayPool.Return(encodeOutput); } } @@ -84,7 +84,7 @@ namespace Ayu.Discord.Voice var secretKey = gw.SecretKey; if (secretKey is null) { - return (int) SendPcmError.SecretKeyUnavailable; + return (int)SendPcmError.SecretKeyUnavailable; } // form RTP header @@ -105,7 +105,7 @@ namespace Ayu.Discord.Voice var timestampBytes = BitConverter.GetBytes(gw.Timestamp); // 4 var ssrcBytes = BitConverter.GetBytes(gw.Ssrc); // 4 - gw.Timestamp += (uint) FrameSizePerChannel; + gw.Timestamp += (uint)FrameSizePerChannel; gw.Sequence++; gw.NonceSequence++; diff --git a/src/EllieBot.Voice/VoiceGateway.cs b/src/EllieBot.Voice/VoiceGateway.cs new file mode 100644 index 0000000..af20aa3 --- /dev/null +++ b/src/EllieBot.Voice/VoiceGateway.cs @@ -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 Result { get; } + + public QueueItem(VoicePayload payload, TaskCompletionSource 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 _channel; + + public TaskCompletionSource 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(); + 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 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(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(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(); + await HandleReadyAsync(ready!); + _shouldResume = true; + break; + case VoiceOpCode.Heartbeat: + // sent, not received + break; + case VoiceOpCode.SessionDescription: + var sd = payload.Data.ToObject(); + 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(); + 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(TaskCreationOptions.RunContinuationsAsynchronously); + var queueItem = new QueueItem(payload, complete); + + if (!_channel.Writer.TryWrite(queueItem)) + await _channel.Writer.WriteAsync(queueItem); + + await complete.Task; + } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/.dockerignore b/src/EllieBot.VotesApi/.dockerignore new file mode 100644 index 0000000..4c2af91 --- /dev/null +++ b/src/EllieBot.VotesApi/.dockerignore @@ -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 \ No newline at end of file diff --git a/src/EllieBot.VotesApi/.gitignore b/src/EllieBot.VotesApi/.gitignore new file mode 100644 index 0000000..9ae80d3 --- /dev/null +++ b/src/EllieBot.VotesApi/.gitignore @@ -0,0 +1 @@ +store/ \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Common/AuthHandler.cs b/src/EllieBot.VotesApi/Common/AuthHandler.cs new file mode 100644 index 0000000..fbe7aa8 --- /dev/null +++ b/src/EllieBot.VotesApi/Common/AuthHandler.cs @@ -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 + { + 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 options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration conf) + : base(options, logger, encoder) + => _conf = conf; + + protected override Task HandleAuthenticateAsync() + { + var claims = new List(); + + 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))); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Common/ConfKeys.cs b/src/EllieBot.VotesApi/Common/ConfKeys.cs new file mode 100644 index 0000000..dd7de64 --- /dev/null +++ b/src/EllieBot.VotesApi/Common/ConfKeys.cs @@ -0,0 +1,8 @@ +namespace EllieBot.VotesApi +{ + public static class ConfKeys + { + public const string DISCORDS_KEY = "DiscordsKey"; + public const string TOPGG_KEY = "TopGGKey"; + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Common/DiscordsVoteWebhookModel.cs b/src/EllieBot.VotesApi/Common/DiscordsVoteWebhookModel.cs new file mode 100644 index 0000000..09522b7 --- /dev/null +++ b/src/EllieBot.VotesApi/Common/DiscordsVoteWebhookModel.cs @@ -0,0 +1,26 @@ +namespace EllieBot.VotesApi +{ + public class DiscordsVoteWebhookModel + { + /// + /// The ID of the user who voted + /// + public string User { get; set; } + + /// + /// The ID of the bot which recieved the vote + /// + public string Bot { get; set; } + + /// + /// 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 + /// + public string Votes { get; set; } + + /// + /// The type of event, whether it is a vote event or test event + /// + public string Type { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Common/Policies.cs b/src/EllieBot.VotesApi/Common/Policies.cs new file mode 100644 index 0000000..d4c59d0 --- /dev/null +++ b/src/EllieBot.VotesApi/Common/Policies.cs @@ -0,0 +1,8 @@ +namespace EllieBot.VotesApi +{ + public static class Policies + { + public const string DiscordsAuth = "DiscordsAuth"; + public const string TopggAuth = "TopggAuth"; + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Common/TopggVoteWebhookModel.cs b/src/EllieBot.VotesApi/Common/TopggVoteWebhookModel.cs new file mode 100644 index 0000000..bdfd8a9 --- /dev/null +++ b/src/EllieBot.VotesApi/Common/TopggVoteWebhookModel.cs @@ -0,0 +1,30 @@ +namespace EllieBot.VotesApi +{ + public class TopggVoteWebhookModel + { + /// + /// Discord ID of the bot that received a vote. + /// + public string Bot { get; set; } + + /// + /// Discord ID of the user who voted. + /// + public string User { get; set; } + + /// + /// The type of the vote (should always be "upvote" except when using the test button it's "test"). + /// + public string Type { get; set; } + + /// + /// Whether the weekend multiplier is in effect, meaning users votes count as two. + /// + public bool Weekend { get; set; } + + /// + /// Query string params found on the /bot/:ID/vote page. Example: ?a=1&b=2. + /// + public string Query { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Controllers/DiscordsController.cs b/src/EllieBot.VotesApi/Controllers/DiscordsController.cs new file mode 100644 index 0000000..183db84 --- /dev/null +++ b/src/EllieBot.VotesApi/Controllers/DiscordsController.cs @@ -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 _logger; + private readonly IVotesCache _cache; + + public DiscordsController(ILogger logger, IVotesCache cache) + { + _logger = logger; + _cache = cache; + } + + [HttpGet("new")] + [Authorize(Policy = Policies.DiscordsAuth)] + public async Task> New() + { + var votes = await _cache.GetNewDiscordsVotesAsync(); + if (votes.Count > 0) + _logger.LogInformation("Sending {NewDiscordsVotes} new discords votes", votes.Count); + return votes; + } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Controllers/TopGgController.cs b/src/EllieBot.VotesApi/Controllers/TopGgController.cs new file mode 100644 index 0000000..28fb5a7 --- /dev/null +++ b/src/EllieBot.VotesApi/Controllers/TopGgController.cs @@ -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 _logger; + private readonly IVotesCache _cache; + + public TopGgController(ILogger logger, IVotesCache cache) + { + _logger = logger; + _cache = cache; + } + + [HttpGet("new")] + [Authorize(Policy = Policies.TopggAuth)] + public async Task> New() + { + var votes = await _cache.GetNewTopGgVotesAsync(); + if (votes.Count > 0) + _logger.LogInformation("Sending {NewTopggVotes} new topgg votes", votes.Count); + + return votes; + } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Controllers/WebhookController.cs b/src/EllieBot.VotesApi/Controllers/WebhookController.cs new file mode 100644 index 0000000..51dcfd6 --- /dev/null +++ b/src/EllieBot.VotesApi/Controllers/WebhookController.cs @@ -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 _logger; + private readonly IVotesCache _votesCache; + + public WebhookController(ILogger logger, IVotesCache votesCache) + { + _logger = logger; + _votesCache = votesCache; + } + + [HttpPost("/discordswebhook")] + [Authorize(Policy = Policies.DiscordsAuth)] + public async Task 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 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(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Dockerfile b/src/EllieBot.VotesApi/Dockerfile new file mode 100644 index 0000000..4597cb2 --- /dev/null +++ b/src/EllieBot.VotesApi/Dockerfile @@ -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"] diff --git a/src/EllieBot.VotesApi/EllieBot.VotesApi.csproj b/src/EllieBot.VotesApi/EllieBot.VotesApi.csproj new file mode 100644 index 0000000..af0ed1f --- /dev/null +++ b/src/EllieBot.VotesApi/EllieBot.VotesApi.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + Linux + + + + + + + + diff --git a/src/EllieBot.VotesApi/Program.cs b/src/EllieBot.VotesApi/Program.cs new file mode 100644 index 0000000..20b7d0a --- /dev/null +++ b/src/EllieBot.VotesApi/Program.cs @@ -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(); }); \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Properties/launchSettings.json b/src/EllieBot.VotesApi/Properties/launchSettings.json new file mode 100644 index 0000000..5112c07 --- /dev/null +++ b/src/EllieBot.VotesApi/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:16451", + "sslPort": 44323 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "EllieBot.VotesApi": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/EllieBot.VotesApi/README.md b/src/EllieBot.VotesApi/README.md new file mode 100644 index 0000000..4c22299 --- /dev/null +++ b/src/EllieBot.VotesApi/README.md @@ -0,0 +1,46 @@ +## Votes Api + +This api is used if you want your bot to be able to reward users who vote for it on discords.com or top.gg + +#### [GET] `/discords/new` + Get the discords votes received after previous call to this endpoint. + Input full url of this endpoint in your creds.yml file under Discords url field. + For example "https://api.my.cool.bot/discords/new" +#### [GET] `/topgg/new` + Get the topgg votes received after previous call to this endpoint. + Input full url of this endpoint in your creds.yml file under Topgg url field. + For example "https://api.my.cool.bot/topgg/new" + +#### [POST] `/discordswebhook` + Input this endpoint as the webhook on discords.com bot edit page + model: https://docs.botsfordiscord.com/methods/receiving-votes + For example "https://api.my.cool.bot/topggwebhook" +#### [POST] `/topggwebhook` + Input this endpoint as the webhook https://top.gg/bot/:your-bot-id/webhooks (replace :your-bot-id with your bot's id) + model: https://docs.top.gg/resources/webhooks/#schema + For example "https://api.my.cool.bot/discordswebhook" + +Input your super-secret header value in appsettings.json's DiscordsKey and TopGGKey fields +They must match your DiscordsKey and TopGG key respectively, as well as your secrets in the discords.com and top.gg webhook setup pages + +Full Example: + +⚠ Change TopggKey and DiscordsKey to a secure long string +⚠ You can use https://www.random.org/strings/?num=1&len=20&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new to generate it + +`creds.yml` +```yml +votes: + TopggServiceUrl: "https://api.my.cool.bot/topgg" + TopggKey: "my_topgg_key" + DiscordsServiceUrl: "https://api.my.cool.bot/discords" + DiscordsKey: "my_discords_key" +``` + +`appsettings.json` +```json +... + "DiscordsKey": "my_discords_key", + "TopGGKey": "my_topgg_key", +... +``` \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Services/FileVotesCache.cs b/src/EllieBot.VotesApi/Services/FileVotesCache.cs new file mode 100644 index 0000000..77b963a --- /dev/null +++ b/src/EllieBot.VotesApi/Services/FileVotesCache.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using MorseCode.ITask; + +namespace EllieBot.VotesApi.Services +{ + public class FileVotesCache : IVotesCache + { + // private const string STATS_FILE = "store/stats.json"; + private const string TOPGG_FILE = "store/topgg.json"; + private const string DISCORDS_FILE = "store/discords.json"; + + private readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1); + + public FileVotesCache() + { + if (!Directory.Exists("store")) + Directory.CreateDirectory("store"); + + if (!File.Exists(TOPGG_FILE)) + File.WriteAllText(TOPGG_FILE, "[]"); + + if (!File.Exists(DISCORDS_FILE)) + File.WriteAllText(DISCORDS_FILE, "[]"); + } + + public ITask AddNewTopggVote(string userId) + => AddNewVote(TOPGG_FILE, userId); + + public ITask AddNewDiscordsVote(string userId) + => AddNewVote(DISCORDS_FILE, userId); + + private async ITask AddNewVote(string file, string userId) + { + await _locker.WaitAsync(); + try + { + var votes = await GetVotesAsync(file); + votes.Add(userId); + await File.WriteAllTextAsync(file, JsonSerializer.Serialize(votes)); + } + finally + { + _locker.Release(); + } + } + + public async ITask> GetNewTopGgVotesAsync() + { + var votes = await EvictTopggVotes(); + return votes; + } + + public async ITask> GetNewDiscordsVotesAsync() + { + var votes = await EvictDiscordsVotes(); + return votes; + } + + private ITask> EvictTopggVotes() + => EvictVotes(TOPGG_FILE); + + private ITask> EvictDiscordsVotes() + => EvictVotes(DISCORDS_FILE); + + private async ITask> EvictVotes(string file) + { + await _locker.WaitAsync(); + try + { + + var ids = await GetVotesAsync(file); + await File.WriteAllTextAsync(file, "[]"); + + return ids? + .Select(x => (Ok: ulong.TryParse(x, out var r), Id: r)) + .Where(x => x.Ok) + .Select(x => new Vote + { + UserId = x.Id + }) + .ToList(); + } + finally + { + _locker.Release(); + } + } + + private async ITask> GetVotesAsync(string file) + { + await using var fs = File.Open(file, FileMode.Open); + var votes = await JsonSerializer.DeserializeAsync>(fs); + return votes; + } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Services/IVotesCache.cs b/src/EllieBot.VotesApi/Services/IVotesCache.cs new file mode 100644 index 0000000..0bc25de --- /dev/null +++ b/src/EllieBot.VotesApi/Services/IVotesCache.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using MorseCode.ITask; + +namespace EllieBot.VotesApi.Services +{ + public interface IVotesCache + { + ITask> GetNewTopGgVotesAsync(); + ITask> GetNewDiscordsVotesAsync(); + ITask AddNewTopggVote(string userId); + ITask AddNewDiscordsVote(string userId); + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/Startup.cs b/src/EllieBot.VotesApi/Startup.cs new file mode 100644 index 0000000..c1d850f --- /dev/null +++ b/src/EllieBot.VotesApi/Startup.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; +using EllieBot.VotesApi.Services; + +namespace EllieBot.VotesApi +{ + public class Startup + { + public IConfiguration Configuration { get; } + + public Startup(IConfiguration configuration) + => Configuration = configuration; + + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + services.AddSingleton(); + services.AddSwaggerGen(static c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "EllieBot.VotesApi", Version = "v1" }); + }); + + services + .AddAuthentication(opts => + { + opts.DefaultScheme = AuthHandler.SchemeName; + opts.AddScheme(AuthHandler.SchemeName, AuthHandler.SchemeName); + }); + + services + .AddAuthorization(static opts => + { + opts.DefaultPolicy = new AuthorizationPolicyBuilder(AuthHandler.SchemeName) + .RequireAssertion(static _ => false) + .Build(); + opts.AddPolicy(Policies.DiscordsAuth, static policy => policy.RequireClaim(AuthHandler.DiscordsClaim)); + opts.AddPolicy(Policies.TopggAuth, static policy => policy.RequireClaim(AuthHandler.TopggClaim)); + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseSwagger(); + app.UseSwaggerUI(static c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "EllieBot.VotesApi v1")); + } + + app.UseHttpsRedirection(); + + app.UseRouting(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(static endpoints => { endpoints.MapControllers(); }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/WeatherForecast.cs b/src/EllieBot.VotesApi/WeatherForecast.cs new file mode 100644 index 0000000..a1e8f62 --- /dev/null +++ b/src/EllieBot.VotesApi/WeatherForecast.cs @@ -0,0 +1,7 @@ +namespace EllieBot.VotesApi +{ + public class Vote + { + public ulong UserId { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot.VotesApi/appsettings.Development.json b/src/EllieBot.VotesApi/appsettings.Development.json new file mode 100644 index 0000000..8983e0f --- /dev/null +++ b/src/EllieBot.VotesApi/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/EllieBot.VotesApi/appsettings.json b/src/EllieBot.VotesApi/appsettings.json new file mode 100644 index 0000000..7b5f330 --- /dev/null +++ b/src/EllieBot.VotesApi/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "DiscordsKey": "my_discords_key", + "TopGGKey": "my_topgg_key", + "AllowedHosts": "*" +} diff --git a/src/EllieBot/.editorconfig b/src/EllieBot/.editorconfig new file mode 100644 index 0000000..041e023 --- /dev/null +++ b/src/EllieBot/.editorconfig @@ -0,0 +1,359 @@ +root = true +# Remove the line below if you want to inherit .editorconfig settings from higher directories + +[obj/**] +generated_code = true + +# C# files +[*.cs] + + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = always:error + +# Expression-level preferences +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = false:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:error +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true:suggestion + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:warning + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = true +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_constructors = when_on_single_line:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:error +csharp_style_pattern_matching_over_is_with_cast_check = true:error +csharp_style_prefer_not_pattern = true:error +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:error + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async + +# Code-block preferences +csharp_prefer_braces = when_multiline:warning +csharp_prefer_simple_using_statement = true + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true:error +csharp_style_inlined_variable_declaration = true:warning +csharp_style_pattern_local_over_anonymous_function = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_range_operator = true +csharp_style_throw_expression = true:error +csharp_style_unused_value_assignment_preference = discard_variable:warning +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:error + +# Enforce file-scoped namespaces +csharp_style_namespace_declarations = file_scoped:error + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false +csharp_style_allow_embedded_statements_on_same_line_experimental = false + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.private_readonly_field.symbols = private_readonly_field +dotnet_naming_rule.private_readonly_field.style = begins_with_underscore +dotnet_naming_rule.private_readonly_field.severity = warning + +dotnet_naming_rule.private_field.symbols = private_field +dotnet_naming_rule.private_field.style = camel_case +dotnet_naming_rule.private_field.severity = warning + +dotnet_naming_rule.const_fields.symbols = const_fields +dotnet_naming_rule.const_fields.style = all_upper +dotnet_naming_rule.const_fields.severity = warning + +# dotnet_naming_rule.class_should_be_pascal_case.severity = error +# dotnet_naming_rule.class_should_be_pascal_case.symbols = class +# dotnet_naming_rule.class_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.struct_should_be_pascal_case.severity = error +dotnet_naming_rule.struct_should_be_pascal_case.symbols = struct +dotnet_naming_rule.struct_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = error +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +# dotnet_naming_rule.types_should_be_pascal_case.severity = error +# dotnet_naming_rule.types_should_be_pascal_case.symbols = types +# dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +# dotnet_naming_rule.enum_should_be_pascal_case.severity = error +# dotnet_naming_rule.enum_should_be_pascal_case.symbols = enum +# dotnet_naming_rule.enum_should_be_pascal_case.style = pascal_case + +# dotnet_naming_rule.property_should_be_pascal_case.severity = error +# dotnet_naming_rule.property_should_be_pascal_case.symbols = property +# dotnet_naming_rule.property_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.method_should_be_pascal_case.severity = error +dotnet_naming_rule.method_should_be_pascal_case.symbols = method +dotnet_naming_rule.method_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.async_method_should_be_ends_with_async.severity = error +dotnet_naming_rule.async_method_should_be_ends_with_async.symbols = async_method +dotnet_naming_rule.async_method_should_be_ends_with_async.style = ends_with_async + +# dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error +# dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +# dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.local_variable_should_be_camel_case.severity = error +dotnet_naming_rule.local_variable_should_be_camel_case.symbols = local_variable +dotnet_naming_rule.local_variable_should_be_camel_case.style = camel_case + +# Symbol specifications + +dotnet_naming_symbols.const_fields.required_modifiers = const +dotnet_naming_symbols.const_fields.applicable_kinds = field + +dotnet_naming_symbols.class.applicable_kinds = class +dotnet_naming_symbols.class.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.class.required_modifiers = + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.struct.applicable_kinds = struct +dotnet_naming_symbols.struct.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.struct.required_modifiers = + +dotnet_naming_symbols.enum.applicable_kinds = enum +dotnet_naming_symbols.enum.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enum.required_modifiers = + +dotnet_naming_symbols.method.applicable_kinds = method +dotnet_naming_symbols.method.applicable_accessibilities = public +dotnet_naming_symbols.method.required_modifiers = + +dotnet_naming_symbols.property.applicable_kinds = property +dotnet_naming_symbols.property.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.property.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.private_readonly_field.applicable_kinds = field +dotnet_naming_symbols.private_readonly_field.applicable_accessibilities = private, protected +dotnet_naming_symbols.private_readonly_field.required_modifiers = readonly + +dotnet_naming_symbols.private_field.applicable_kinds = field +dotnet_naming_symbols.private_field.applicable_accessibilities = private, protected +dotnet_naming_symbols.private_field.required_modifiers = + +dotnet_naming_symbols.async_method.applicable_kinds = method, local_function +dotnet_naming_symbols.async_method.applicable_accessibilities = * +dotnet_naming_symbols.async_method.required_modifiers = async + +dotnet_naming_symbols.local_variable.applicable_kinds = parameter, local +dotnet_naming_symbols.local_variable.applicable_accessibilities = local +dotnet_naming_symbols.local_variable.required_modifiers = + +# Naming styles + + +dotnet_naming_style.all_upper.capitalization = all_upper + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.begins_with_underscore.required_prefix = _ +dotnet_naming_style.begins_with_underscore.required_suffix = +dotnet_naming_style.begins_with_underscore.word_separator = +dotnet_naming_style.begins_with_underscore.capitalization = camel_case + +dotnet_naming_style.ends_with_async.required_prefix = +# dotnet_naming_style.ends_with_async.required_suffix = Async +dotnet_naming_style.ends_with_async.word_separator = +dotnet_naming_style.ends_with_async.capitalization = pascal_case + +dotnet_naming_style.camel_case.required_prefix = +dotnet_naming_style.camel_case.required_suffix = +dotnet_naming_style.camel_case.word_separator = +dotnet_naming_style.camel_case.capitalization = camel_case + +# CA1822: Mark members as static +dotnet_diagnostic.ca1822.severity = suggestion + +# IDE0004: Cast is redundant +dotnet_diagnostic.ide0004.severity = warning + +# IDE0058: Expression value is never used +dotnet_diagnostic.ide0058.severity = none + +# # IDE0011: Add braces to 'if'/'else' statement +# dotnet_diagnostic.ide0011.severity = none + +resharper_wrap_after_invocation_lpar = false +resharper_wrap_before_invocation_rpar = false + +# ReSharper properties +resharper_align_multiline_calls_chain = true +resharper_csharp_wrap_after_declaration_lpar = true +resharper_csharp_wrap_after_invocation_lpar = false +resharper_csharp_wrap_before_binary_opsign = true +resharper_csharp_wrap_before_invocation_rpar = false +resharper_csharp_wrap_parameters_style = chop_if_long +resharper_force_chop_compound_if_expression = false +resharper_keep_existing_linebreaks = true +resharper_keep_user_linebreaks = true +resharper_max_formal_parameters_on_line = 3 +resharper_place_simple_embedded_statement_on_same_line = false +resharper_wrap_chained_binary_expressions = chop_if_long +resharper_wrap_chained_binary_patterns = chop_if_long +resharper_wrap_chained_method_calls = chop_if_long +resharper_wrap_object_and_collection_initializer_style = chop_always + +resharper_csharp_wrap_before_first_type_parameter_constraint = true +resharper_csharp_place_type_constraints_on_same_line = false +resharper_csharp_wrap_before_extends_colon = true +resharper_csharp_place_constructor_initializer_on_same_line = false +resharper_force_attribute_style = separate +resharper_csharp_braces_for_ifelse = required_for_multiline_statement +resharper_csharp_braces_for_foreach = required_for_multiline +resharper_csharp_braces_for_while = required_for_multiline +resharper_csharp_braces_for_for = required_for_multiline +resharper_arrange_redundant_parentheses_highlighting = hint + +# IDE0011: Add braces +dotnet_diagnostic.IDE0011.severity = warning diff --git a/src/EllieBot/Bot.cs b/src/EllieBot/Bot.cs new file mode 100644 index 0000000..1ce8774 --- /dev/null +++ b/src/EllieBot/Bot.cs @@ -0,0 +1,380 @@ +#nullable disable +using DryIoc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Common.Configs; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Reflection; +using RunMode = Discord.Commands.RunMode; + +namespace EllieBot; + +public sealed class Bot : IBot +{ + public event Func JoinedGuild = delegate { return Task.CompletedTask; }; + + public DiscordSocketClient Client { get; } + public IReadOnlyCollection AllGuildConfigs { get; private set; } + + private IContainer Services { get; set; } + + public bool IsReady { get; private set; } + public int ShardId { get; set; } + + private readonly IBotCredentials _creds; + private readonly CommandService _commandService; + private readonly DbService _db; + + private readonly IBotCredsProvider _credsProvider; + + private readonly Assembly[] _loadedAssemblies; + // private readonly InteractionService _interactionService; + + public Bot(int shardId, int? totalShards, string credPath = null) + { + ArgumentOutOfRangeException.ThrowIfLessThan(shardId, 0); + + ShardId = shardId; + _credsProvider = new BotCredsProvider(totalShards, credPath); + _creds = _credsProvider.GetCreds(); + + _db = new EllieDbService(_credsProvider); + + var messageCacheSize = +#if GLOBAL_ELLIE + 0; +#else + 50; +#endif + + if (!_creds.UsePrivilegedIntents) + Log.Warning("You are not using privileged intents. Some features will not work properly"); + + Client = new(new() + { + MessageCacheSize = messageCacheSize, + LogLevel = LogSeverity.Warning, + ConnectionTimeout = int.MaxValue, + TotalShards = _creds.TotalShards, + ShardId = shardId, + AlwaysDownloadUsers = false, + AlwaysResolveStickers = false, + AlwaysDownloadDefaultStickers = false, + GatewayIntents = _creds.UsePrivilegedIntents + ? GatewayIntents.All + : GatewayIntents.AllUnprivileged, + LogGatewayIntentWarnings = false, + FormatUsersInBidirectionalUnicode = false, + DefaultRetryMode = RetryMode.Retry502 + }); + + _commandService = new(new() + { + CaseSensitiveCommands = false, + DefaultRunMode = RunMode.Sync, + }); + + // _interactionService = new(Client.Rest); + + Client.Log += Client_Log; + _loadedAssemblies = + [ + typeof(Bot).Assembly // bot + ]; + } + + + public IReadOnlyList GetCurrentGuildIds() + => Client.Guilds.Select(x => x.Id).ToList(); + + private void AddServices() + { + var startingGuildIdList = GetCurrentGuildIds(); + var sw = Stopwatch.StartNew(); + var bot = Client.CurrentUser; + + using (var uow = _db.GetDbContext()) + { + uow.EnsureUserCreated(bot.Id, bot.Username, bot.Discriminator, bot.AvatarId); + AllGuildConfigs = uow.Set().GetAllGuildConfigs(startingGuildIdList).ToImmutableArray(); + } + + // var svcs = new StandardKernel(new NinjectSettings() + // { + // // ThrowOnGetServiceNotFound = true, + // ActivationCacheDisabled = true, + // }); + + var svcs = new Container(); + + // this is required in order for medusa unloading to work + // svcs.Components.Remove(); + // svcs.Components.Add(); + + svcs.AddSingleton(_ => _credsProvider.GetCreds()); + svcs.AddSingleton(_db); + svcs.AddSingleton(_credsProvider); + svcs.AddSingleton(Client); + svcs.AddSingleton(_commandService); + svcs.AddSingleton(this); + svcs.AddSingleton(this); + + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(new MemoryCache(new MemoryCacheOptions())); + svcs.AddSingleton(); + svcs.AddSingleton(); + + + foreach (var a in _loadedAssemblies) + { + svcs.AddConfigServices(a) + .AddLifetimeServices(a); + } + + svcs.AddMusic() + .AddCache(_creds) + .AddHttpClients(); + + if (Environment.GetEnvironmentVariable("ELLIEBOT_IS_COORDINATED") != "1") + { + svcs.AddSingleton(); + } + else + { + svcs.AddSingleton(); + svcs.AddSingleton(_ => svcs.GetRequiredService()); + svcs.AddSingleton(_ => svcs.GetRequiredService()); + } + + svcs.AddSingleton(svcs); + + //initialize Services + Services = svcs; + Services.GetRequiredService().Initialize(); + + foreach (var a in _loadedAssemblies) + { + LoadTypeReaders(a); + } + + sw.Stop(); + Log.Information("All services loaded in {ServiceLoadTime:F2}s", sw.Elapsed.TotalSeconds); + } + + private void LoadTypeReaders(Assembly assembly) + { + var filteredTypes = assembly.GetExportedTypes() + .Where(x => x.IsSubclassOf(typeof(TypeReader)) + && x.BaseType?.GetGenericArguments().Length > 0 + && !x.IsAbstract); + + foreach (var ft in filteredTypes) + { + var baseType = ft.BaseType; + if (baseType is null) + continue; + + var typeReader = (TypeReader)ActivatorUtilities.CreateInstance(Services, ft); + var typeArgs = baseType.GetGenericArguments(); + _commandService.AddTypeReader(typeArgs[0], typeReader); + } + } + + private async Task LoginAsync(string token) + { + var clientReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async Task SetClientReady() + { + clientReady.TrySetResult(true); + try + { + foreach (var chan in await Client.GetDMChannelsAsync()) + await chan.CloseAsync(); + } + catch + { + // ignored + } + } + + //connect + Log.Information("Shard {ShardId} logging in ...", Client.ShardId); + try + { + Client.Ready += SetClientReady; + + await Client.LoginAsync(TokenType.Bot, token); + await Client.StartAsync(); + } + catch (HttpException ex) + { + LoginErrorHandler.Handle(ex); + Helpers.ReadErrorAndExit(3); + } + catch (Exception ex) + { + LoginErrorHandler.Handle(ex); + Helpers.ReadErrorAndExit(4); + } + + await clientReady.Task.ConfigureAwait(false); + Client.Ready -= SetClientReady; + + Client.JoinedGuild += Client_JoinedGuild; + Client.LeftGuild += Client_LeftGuild; + + // _ = Client.SetStatusAsync(UserStatus.Online); + Log.Information("Shard {ShardId} logged in", Client.ShardId); + } + + private Task Client_LeftGuild(SocketGuild arg) + { + Log.Information("Left server: {GuildName} [{GuildId}]", arg?.Name, arg?.Id); + return Task.CompletedTask; + } + + private Task Client_JoinedGuild(SocketGuild arg) + { + Log.Information("Joined server: {GuildName} [{GuildId}]", arg.Name, arg.Id); + _ = Task.Run(async () => + { + GuildConfig gc; + await using (var uow = _db.GetDbContext()) + { + gc = uow.GuildConfigsForId(arg.Id, null); + } + + await JoinedGuild.Invoke(gc); + }); + return Task.CompletedTask; + } + + public async Task RunAsync() + { + if (ShardId == 0) + await _db.SetupAsync(); + + var sw = Stopwatch.StartNew(); + + await LoginAsync(_creds.Token); + + Log.Information("Shard {ShardId} loading services...", Client.ShardId); + try + { + AddServices(); + } + catch (Exception ex) + { + Log.Error(ex, "Error adding services"); + Helpers.ReadErrorAndExit(9); + } + + sw.Stop(); + Log.Information("Shard {ShardId} connected in {Elapsed:F2}s", Client.ShardId, sw.Elapsed.TotalSeconds); + var commandHandler = Services.GetRequiredService(); + + // start handling messages received in commandhandler + await commandHandler.StartHandling(); + + foreach (var a in _loadedAssemblies) + { + await _commandService.AddModulesAsync(a, Services); + } + + // await _interactionService.AddModulesAsync(typeof(Bot).Assembly, Services); + IsReady = true; + + await EnsureBotOwnershipAsync(); + _ = Task.Run(ExecuteReadySubscriptions); + Log.Information("Shard {ShardId} ready", Client.ShardId); + } + + private async ValueTask EnsureBotOwnershipAsync() + { + try + { + if (_creds.OwnerIds.Count != 0) + return; + + Log.Information("Initializing Owner Id..."); + var info = await Client.GetApplicationInfoAsync(); + _credsProvider.ModifyCredsFile(x => x.OwnerIds = new[] { info.Owner.Id }); + } + catch (Exception ex) + { + Log.Warning("Getting application info failed: {ErrorMessage}", ex.Message); + } + } + + private Task ExecuteReadySubscriptions() + { + var readyExecutors = Services.GetServices(); + var tasks = readyExecutors.Select(async toExec => + { + try + { + await toExec.OnReadyAsync(); + } + catch (Exception ex) + { + Log.Error(ex, + "Failed running OnReadyAsync method on {Type} type: {Message}", + toExec.GetType().Name, + ex.Message); + } + }); + + return tasks.WhenAll(); + } + + private Task Client_Log(LogMessage arg) + { + if (arg.Message?.Contains("unknown dispatch", StringComparison.InvariantCultureIgnoreCase) ?? false) + return Task.CompletedTask; + + if (arg.Exception is { InnerException: WebSocketClosedException { CloseCode: 4014 } }) + { + Log.Error(""" + Login failed. + + *** Please enable privileged intents *** + + Certain Ellie features require Discord's privileged gateway intents. + These include greeting and goodbye messages, as well as creating the Owner message channels for DM forwarding. + + How to enable privileged intents: + 1. Head over to the Discord Developer Portal https://discord.com/developers/applications/ + 2. Select your Application. + 3. Click on `Bot` in the left side navigation panel, and scroll down to the intents section. + 4. Enable all intents. + 5. Restart your bot. + + Read this only if your bot is in 100 or more servers: + + You'll need to apply to use the intents with Discord, but for small selfhosts, all that is required is enabling the intents in the developer portal. + Yes, this is a new thing from Discord, as of October 2020. No, there's nothing we can do about it. Yes, we're aware it worked before. + While waiting for your bot to be accepted, you can change the 'usePrivilegedIntents' inside your creds.yml to 'false', although this will break many of the ellie's features + """); + return Task.CompletedTask; + } + +#if GLOBAL_ELLIE || DEBUG + if (arg.Exception is not null) + Log.Warning(arg.Exception, "{ErrorSource} | {ErrorMessage}", arg.Source, arg.Message); + else + Log.Warning("{ErrorSource} | {ErrorMessage}", arg.Source, arg.Message); +#endif + return Task.CompletedTask; + } + + public async Task RunAndBlockAsync() + { + await RunAsync(); + await Task.Delay(-1); + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/EllieContext.cs b/src/EllieBot/Db/EllieContext.cs new file mode 100644 index 0000000..263963b --- /dev/null +++ b/src/EllieBot/Db/EllieContext.cs @@ -0,0 +1,700 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using EllieBot.Db.Models; + +// ReSharper disable UnusedAutoPropertyAccessor.Global + +namespace EllieBot.Db; + +public abstract class EllieContext : DbContext +{ + public DbSet GuildConfigs { get; set; } + + public DbSet Quotes { get; set; } + public DbSet Reminders { get; set; } + public DbSet SelfAssignableRoles { get; set; } + public DbSet MusicPlaylists { get; set; } + public DbSet Expressions { get; set; } + public DbSet CurrencyTransactions { get; set; } + public DbSet WaifuUpdates { get; set; } + public DbSet WaifuItem { get; set; } + public DbSet Warnings { get; set; } + public DbSet UserXpStats { get; set; } + public DbSet Clubs { get; set; } + public DbSet ClubBans { get; set; } + public DbSet ClubApplicants { get; set; } + + + //logging + public DbSet LogSettings { get; set; } + public DbSet IgnoredLogChannels { get; set; } + + public DbSet RotatingStatus { get; set; } + public DbSet Blacklist { get; set; } + public DbSet AutoCommands { get; set; } + public DbSet RewardedUsers { get; set; } + public DbSet PlantedCurrency { get; set; } + public DbSet BanTemplates { get; set; } + public DbSet DiscordPermOverrides { get; set; } + public DbSet DiscordUser { get; set; } + public DbSet MusicPlayerSettings { get; set; } + public DbSet Repeaters { get; set; } + public DbSet WaifuInfo { get; set; } + public DbSet ImageOnlyChannels { get; set; } + public DbSet AutoTranslateChannels { get; set; } + public DbSet AutoTranslateUsers { get; set; } + + public DbSet Permissions { get; set; } + + public DbSet BankUsers { get; set; } + + public DbSet ReactionRoles { get; set; } + + public DbSet Patrons { get; set; } + + public DbSet PatronQuotas { get; set; } + + public DbSet StreamOnlineMessages { get; set; } + + public DbSet StickyRoles { get; set; } + + public DbSet Todos { get; set; } + public DbSet TodosArchive { get; set; } + + // todo add guild colors + // public DbSet GuildColors { get; set; } + + + #region Mandatory Provider-Specific Values + + protected abstract string CurrencyTransactionOtherIdDefaultValue { get; } + + #endregion + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region QUOTES + + var quoteEntity = modelBuilder.Entity(); + quoteEntity.HasIndex(x => x.GuildId); + quoteEntity.HasIndex(x => x.Keyword); + + #endregion + + #region GuildConfig + + var configEntity = modelBuilder.Entity(); + + configEntity.HasIndex(c => c.GuildId) + .IsUnique(); + + configEntity.Property(x => x.VerboseErrors) + .HasDefaultValue(true); + + modelBuilder.Entity() + .HasMany(x => x.DelMsgOnCmdChannels) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FollowedStreams) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.GenerateCurrencyChannelIds) + .WithOne(x => x.GuildConfig) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Permissions) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.CommandCooldowns) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FilterInvitesChannelIds) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FilterLinksChannelIds) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FilteredWords) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FilterWordsChannelIds) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.MutedUsers) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(x => x.AntiRaidSetting) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + // start antispam + + modelBuilder.Entity() + .HasOne(x => x.AntiSpamSetting) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.IgnoredChannels) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + // end antispam + + modelBuilder.Entity() + .HasOne(x => x.AntiAltSetting) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.UnmuteTimers) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.UnbanTimer) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.UnroleTimer) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.VcRoleInfos) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.CommandAliases) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.WarnPunishments) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.SlowmodeIgnoredRoles) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.SlowmodeIgnoredUsers) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + // start shop + modelBuilder.Entity() + .HasMany(x => x.ShopEntries) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Items) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + // end shop + + // start streamrole + + modelBuilder.Entity() + .HasOne(x => x.StreamRole) + .WithOne(x => x.GuildConfig) + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Whitelist) + .WithOne(x => x.StreamRoleSettings) + .HasForeignKey(x => x.StreamRoleSettingsId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Blacklist) + .WithOne(x => x.StreamRoleSettings) + .HasForeignKey(x => x.StreamRoleSettingsId) + .OnDelete(DeleteBehavior.Cascade); + + // end streamrole + + modelBuilder.Entity() + .HasOne(x => x.XpSettings) + .WithOne(x => x.GuildConfig) + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FeedSubs) + .WithOne(x => x.GuildConfig) + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.SelfAssignableRoleGroupNames) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasAlternateKey(x => new + { + x.GuildConfigId, + x.Url + }); + + modelBuilder.Entity().HasIndex(x => x.MessageId).IsUnique(); + + modelBuilder.Entity().HasIndex(x => x.ChannelId); + + configEntity.HasIndex(x => x.WarnExpireHours).IsUnique(false); + + #endregion + + + #region Self Assignable Roles + + var selfassignableRolesEntity = modelBuilder.Entity(); + + selfassignableRolesEntity.HasIndex(s => new + { + s.GuildId, + s.RoleId + }) + .IsUnique(); + + selfassignableRolesEntity.Property(x => x.Group).HasDefaultValue(0); + + #endregion + + #region MusicPlaylists + + var musicPlaylistEntity = modelBuilder.Entity(); + + musicPlaylistEntity.HasMany(p => p.Songs).WithOne().OnDelete(DeleteBehavior.Cascade); + + #endregion + + #region Waifus + + var wi = modelBuilder.Entity(); + wi.HasOne(x => x.Waifu).WithOne(); + + wi.HasIndex(x => x.Price); + wi.HasIndex(x => x.ClaimerId); + // wi.HasMany(x => x.Items) + // .WithOne() + // .OnDelete(DeleteBehavior.Cascade); + + #endregion + + #region DiscordUser + + modelBuilder.Entity(du => + { + du.Property(x => x.IsClubAdmin) + .HasDefaultValue(false); + + du.Property(x => x.NotifyOnLevelUp) + .HasDefaultValue(XpNotificationLocation.None); + + du.Property(x => x.TotalXp) + .HasDefaultValue(0); + + du.Property(x => x.CurrencyAmount) + .HasDefaultValue(0); + + du.HasAlternateKey(w => w.UserId); + du.HasOne(x => x.Club) + .WithMany(x => x.Members) + .IsRequired(false) + .OnDelete(DeleteBehavior.NoAction); + + du.HasIndex(x => x.TotalXp); + du.HasIndex(x => x.CurrencyAmount); + du.HasIndex(x => x.UserId); + }); + + #endregion + + #region Warnings + + modelBuilder.Entity(warn => + { + warn.HasIndex(x => x.GuildId); + warn.HasIndex(x => x.UserId); + warn.HasIndex(x => x.DateAdded); + warn.Property(x => x.Weight).HasDefaultValue(1); + }); + + #endregion + + #region XpStats + + var xps = modelBuilder.Entity(); + xps.HasIndex(x => new + { + x.UserId, + x.GuildId + }) + .IsUnique(); + + xps.HasIndex(x => x.UserId); + xps.HasIndex(x => x.GuildId); + xps.HasIndex(x => x.Xp); + xps.HasIndex(x => x.AwardedXp); + + #endregion + + #region XpRoleReward + + modelBuilder.Entity() + .HasIndex(x => new + { + x.XpSettingsId, + x.Level + }) + .IsUnique(); + + modelBuilder.Entity() + .HasMany(x => x.RoleRewards) + .WithOne(x => x.XpSettings) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.CurrencyRewards) + .WithOne(x => x.XpSettings) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.ExclusionList) + .WithOne(x => x.XpSettings) + .OnDelete(DeleteBehavior.Cascade); + + #endregion + + #region Club + + var ci = modelBuilder.Entity(); + ci.HasOne(x => x.Owner) + .WithOne() + .HasForeignKey(x => x.OwnerId) + .OnDelete(DeleteBehavior.SetNull); + + ci.HasIndex(x => new + { + x.Name + }) + .IsUnique(); + + #endregion + + #region ClubManytoMany + + modelBuilder.Entity() + .HasKey(t => new + { + t.ClubId, + t.UserId + }); + + modelBuilder.Entity() + .HasOne(pt => pt.User) + .WithMany(); + + modelBuilder.Entity() + .HasOne(pt => pt.Club) + .WithMany(x => x.Applicants); + + modelBuilder.Entity() + .HasKey(t => new + { + t.ClubId, + t.UserId + }); + + modelBuilder.Entity() + .HasOne(pt => pt.User) + .WithMany(); + + modelBuilder.Entity() + .HasOne(pt => pt.Club) + .WithMany(x => x.Bans); + + #endregion + + #region CurrencyTransactions + + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserId) + .IsUnique(false); + + e.Property(x => x.OtherId) + .HasDefaultValueSql(CurrencyTransactionOtherIdDefaultValue); + + e.Property(x => x.Type) + .IsRequired(); + + e.Property(x => x.Extra) + .IsRequired(); + }); + + #endregion + + #region Reminders + + modelBuilder.Entity().HasIndex(x => x.When); + + #endregion + + #region GroupName + + modelBuilder.Entity() + .HasIndex(x => new + { + x.GuildConfigId, + x.Number + }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.GuildConfig) + .WithMany(x => x.SelfAssignableRoleGroupNames) + .IsRequired(); + + #endregion + + #region BanTemplate + + modelBuilder.Entity().HasIndex(x => x.GuildId).IsUnique(); + modelBuilder.Entity() + .Property(x => x.PruneDays) + .HasDefaultValue(null) + .IsRequired(false); + + #endregion + + #region Perm Override + + modelBuilder.Entity() + .HasIndex(x => new + { + x.GuildId, + x.Command + }) + .IsUnique(); + + #endregion + + #region Music + + modelBuilder.Entity().HasIndex(x => x.GuildId).IsUnique(); + + modelBuilder.Entity().Property(x => x.Volume).HasDefaultValue(100); + + #endregion + + #region Reaction roles + + modelBuilder.Entity(rr2 => + { + rr2.HasIndex(x => x.GuildId) + .IsUnique(false); + + rr2.HasIndex(x => new + { + x.MessageId, + x.Emote + }) + .IsUnique(); + }); + + #endregion + + #region LogSettings + + modelBuilder.Entity(ls => ls.HasIndex(x => x.GuildId).IsUnique()); + + modelBuilder.Entity(ls => ls + .HasMany(x => x.LogIgnores) + .WithOne(x => x.LogSetting) + .OnDelete(DeleteBehavior.Cascade)); + + modelBuilder.Entity(ili => ili + .HasIndex(x => new + { + x.LogSettingId, + x.LogItemId, + x.ItemType + }) + .IsUnique()); + + #endregion + + modelBuilder.Entity(ioc => ioc.HasIndex(x => x.ChannelId).IsUnique()); + + var atch = modelBuilder.Entity(); + atch.HasIndex(x => x.GuildId).IsUnique(false); + + atch.HasIndex(x => x.ChannelId).IsUnique(); + + atch.HasMany(x => x.Users).WithOne(x => x.Channel).OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity(atu => atu.HasAlternateKey(x => new + { + x.ChannelId, + x.UserId + })); + + #region BANK + + modelBuilder.Entity(bu => bu.HasIndex(x => x.UserId).IsUnique()); + + #endregion + + + #region Patron + + // currency rewards + var pr = modelBuilder.Entity(); + pr.HasIndex(x => x.PlatformUserId).IsUnique(); + + // patrons + // patrons are not identified by their user id, but by their platform user id + // as multiple accounts (even maybe on different platforms) could have + // the same account connected to them + modelBuilder.Entity(pu => + { + pu.HasIndex(x => x.UniquePlatformUserId).IsUnique(); + pu.HasKey(x => x.UserId); + }); + + // quotes are per user id + modelBuilder.Entity(pq => + { + pq.HasIndex(x => x.UserId).IsUnique(false); + pq.HasKey(x => new + { + x.UserId, + x.FeatureType, + x.Feature + }); + }); + + #endregion + + #region Xp Item Shop + + modelBuilder.Entity( + x => + { + // user can own only one of each item + x.HasIndex(model => new + { + model.UserId, + model.ItemType, + model.ItemKey + }) + .IsUnique(); + }); + + #endregion + + #region AutoPublish + + modelBuilder.Entity(apc => apc + .HasIndex(x => x.GuildId) + .IsUnique()); + + #endregion + + #region GamblingStats + + modelBuilder.Entity(gs => gs + .HasIndex(x => x.Feature) + .IsUnique()); + + #endregion + + #region Sticky Roles + + modelBuilder.Entity(sr => sr.HasIndex(x => new + { + x.GuildId, + x.UserId + }) + .IsUnique()); + + #endregion + + + #region Giveaway + + modelBuilder.Entity() + .HasMany(x => x.Participants) + .WithOne() + .HasForeignKey(x => x.GiveawayId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity(gu => gu + .HasIndex(x => new + { + x.GiveawayId, + x.UserId + }) + .IsUnique()); + + #endregion + + #region Todo + + modelBuilder.Entity() + .HasKey(x => x.Id); + + modelBuilder.Entity() + .HasIndex(x => x.UserId) + .IsUnique(false); + + modelBuilder.Entity() + .HasMany(x => x.Items) + .WithOne() + .HasForeignKey(x => x.ArchiveId) + .OnDelete(DeleteBehavior.Cascade); + + #endregion + } + +#if DEBUG + private static readonly ILoggerFactory _debugLoggerFactory = LoggerFactory.Create(x => x.AddConsole()); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseLoggerFactory(_debugLoggerFactory); +#endif +} \ No newline at end of file diff --git a/src/EllieBot/Db/EllieDbService.cs b/src/EllieBot/Db/EllieDbService.cs new file mode 100644 index 0000000..2a3382e --- /dev/null +++ b/src/EllieBot/Db/EllieDbService.cs @@ -0,0 +1,76 @@ +using LinqToDB.Common; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Db; + +public sealed class EllieDbService : DbService +{ + private readonly IBotCredsProvider _creds; + + // these are props because creds can change at runtime + private string DbType => _creds.GetCreds().Db.Type.ToLowerInvariant().Trim(); + private string ConnString => _creds.GetCreds().Db.ConnectionString; + + public EllieDbService(IBotCredsProvider creds) + { + LinqToDBForEFTools.Initialize(); + Configuration.Linq.DisableQueryCache = true; + + _creds = creds; + } + + public override async Task SetupAsync() + { + var dbType = DbType; + var connString = ConnString; + + await using var context = CreateRawDbContext(dbType, connString); + + // make sure sqlite db is in wal journal mode + if (context is SqliteContext) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL"); + } + + await context.Database.MigrateAsync(); + } + + public override EllieContext CreateRawDbContext(string dbType, string connString) + { + switch (dbType) + { + case "postgresql": + case "postgres": + case "pgsql": + return new PostgreSqlContext(connString); + case "mysql": + return new MysqlContext(connString); + case "sqlite": + return new SqliteContext(connString); + default: + throw new NotSupportedException($"The database provide type of '{dbType}' is not supported."); + } + } + + private EllieContext GetDbContextInternal() + { + var dbType = DbType; + var connString = ConnString; + + var context = CreateRawDbContext(dbType, connString); + if (context is SqliteContext) + { + var conn = context.Database.GetDbConnection(); + conn.Open(); + using var com = conn.CreateCommand(); + com.CommandText = "PRAGMA synchronous=OFF"; + com.ExecuteNonQuery(); + } + + return context; + } + + public override EllieContext GetDbContext() + => GetDbContextInternal(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/ClubExtensions.cs b/src/EllieBot/Db/Extensions/ClubExtensions.cs new file mode 100644 index 0000000..88a851b --- /dev/null +++ b/src/EllieBot/Db/Extensions/ClubExtensions.cs @@ -0,0 +1,34 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class ClubExtensions +{ + private static IQueryable Include(this DbSet clubs) + => clubs.Include(x => x.Owner) + .Include(x => x.Applicants) + .ThenInclude(x => x.User) + .Include(x => x.Bans) + .ThenInclude(x => x.User) + .Include(x => x.Members) + .AsQueryable(); + + public static ClubInfo GetByOwner(this DbSet clubs, ulong userId) + => Include(clubs).FirstOrDefault(c => c.Owner.UserId == userId); + + public static ClubInfo GetByOwnerOrAdmin(this DbSet clubs, ulong userId) + => Include(clubs) + .FirstOrDefault(c => c.Owner.UserId == userId || c.Members.Any(u => u.UserId == userId && u.IsClubAdmin)); + + public static ClubInfo GetByMember(this DbSet clubs, ulong userId) + => Include(clubs).FirstOrDefault(c => c.Members.Any(u => u.UserId == userId)); + + public static ClubInfo GetByName(this DbSet clubs, string name) + => Include(clubs) + .FirstOrDefault(c => c.Name == name); + + public static List GetClubLeaderboardPage(this DbSet clubs, int page) + => clubs.AsNoTracking().OrderByDescending(x => x.Xp).Skip(page * 9).Take(9).ToList(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/CurrencyTransactionExtensions.cs b/src/EllieBot/Db/Extensions/CurrencyTransactionExtensions.cs new file mode 100644 index 0000000..69401b0 --- /dev/null +++ b/src/EllieBot/Db/Extensions/CurrencyTransactionExtensions.cs @@ -0,0 +1,20 @@ +#nullable disable +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class CurrencyTransactionExtensions +{ + public static Task> GetPageFor( + this DbSet set, + ulong userId, + int page) + => set.ToLinqToDBTable() + .Where(x => x.UserId == userId) + .OrderByDescending(x => x.DateAdded) + .Skip(15 * page) + .Take(15) + .ToListAsyncLinqToDB(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/DbExtensions.cs b/src/EllieBot/Db/Extensions/DbExtensions.cs new file mode 100644 index 0000000..fafade9 --- /dev/null +++ b/src/EllieBot/Db/Extensions/DbExtensions.cs @@ -0,0 +1,12 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class DbExtensions +{ + public static T GetById(this DbSet set, int id) + where T : DbEntity + => set.FirstOrDefault(x => x.Id == id); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs b/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs new file mode 100644 index 0000000..bdf3c05 --- /dev/null +++ b/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs @@ -0,0 +1,129 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class DiscordUserExtensions +{ + public static Task GetByUserIdAsync( + this IQueryable set, + ulong userId) + => set.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId); + + public static void EnsureUserCreated( + this DbContext ctx, + ulong userId, + string username, + string discrim, + string avatarId) + => ctx.GetTable() + .InsertOrUpdate( + () => new() + { + UserId = userId, + Username = username, + Discriminator = discrim, + AvatarId = avatarId, + TotalXp = 0, + CurrencyAmount = 0 + }, + old => new() + { + Username = username, + Discriminator = discrim, + AvatarId = avatarId + }, + () => new() + { + UserId = userId + }); + + public static Task EnsureUserCreatedAsync( + this DbContext ctx, + ulong userId) + => ctx.GetTable() + .InsertOrUpdateAsync( + () => new() + { + UserId = userId, + Username = "Unknown", + Discriminator = "????", + AvatarId = string.Empty, + TotalXp = 0, + CurrencyAmount = 0 + }, + old => new() + { + }, + () => new() + { + UserId = userId + }); + + //temp is only used in updatecurrencystate, so that i don't overwrite real usernames/discrims with Unknown + public static DiscordUser GetOrCreateUser( + this DbContext ctx, + ulong userId, + string username, + string discrim, + string avatarId, + Func, IQueryable> includes = null) + { + ctx.EnsureUserCreated(userId, username, discrim, avatarId); + + IQueryable queryable = ctx.Set(); + if (includes is not null) + queryable = includes(queryable); + return queryable.First(u => u.UserId == userId); + } + + + public static int GetUserGlobalRank(this DbSet users, ulong id) + => users.AsQueryable() + .Where(x => x.TotalXp + > users.AsQueryable().Where(y => y.UserId == id).Select(y => y.TotalXp).FirstOrDefault()) + .Count() + + 1; + + public static async Task> GetUsersXpLeaderboardFor(this DbSet users, int page, int perPage) + => await users.ToLinqToDBTable() + .OrderByDescending(x => x.TotalXp) + .Skip(page * perPage) + .Take(perPage) + .ToArrayAsyncLinqToDB(); + + public static Task> GetTopRichest( + this DbSet users, + ulong botId, + int page = 0, + int perPage = 9) + => users.AsQueryable() + .Where(c => c.CurrencyAmount > 0 && botId != c.UserId) + .OrderByDescending(c => c.CurrencyAmount) + .Skip(page * perPage) + .Take(perPage) + .ToListAsyncLinqToDB(); + + public static async Task GetUserCurrencyAsync(this DbSet users, ulong userId) + => (await users.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId))?.CurrencyAmount ?? 0; + + public static void RemoveFromMany(this DbSet users, IEnumerable ids) + { + var items = users.AsQueryable().Where(x => ids.Contains(x.UserId)); + foreach (var item in items) + item.CurrencyAmount = 0; + } + + public static decimal GetTotalCurrency(this DbSet users) + => users.Sum((Func)(x => x.CurrencyAmount)); + + public static decimal GetTopOnePercentCurrency(this DbSet users, ulong botId) + => users.AsQueryable() + .Where(x => x.UserId != botId) + .OrderByDescending(x => x.CurrencyAmount) + .Take(users.Count() / 100 == 0 ? 1 : users.Count() / 100) + .Sum(x => x.CurrencyAmount); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/EllieExpressionExtensions.cs b/src/EllieBot/Db/Extensions/EllieExpressionExtensions.cs new file mode 100644 index 0000000..02a3453 --- /dev/null +++ b/src/EllieBot/Db/Extensions/EllieExpressionExtensions.cs @@ -0,0 +1,15 @@ +#nullable disable +using LinqToDB; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class EllieExpressionExtensions +{ + public static int ClearFromGuild(this DbSet exprs, ulong guildId) + => exprs.Delete(x => x.GuildId == guildId); + + public static IEnumerable ForId(this DbSet exprs, ulong id) + => exprs.AsNoTracking().AsQueryable().Where(x => x.GuildId == id).ToList(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs b/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs new file mode 100644 index 0000000..7d16127 --- /dev/null +++ b/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs @@ -0,0 +1,228 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class GuildConfigExtensions +{ + private static List DefaultWarnPunishments + => + [ + new() + { + Count = 3, + Punishment = PunishmentAction.Kick + }, + + new() + { + Count = 5, + Punishment = PunishmentAction.Ban + } + ]; + + /// + /// Gets full stream role settings for the guild with the specified id. + /// + /// Db Context + /// Id of the guild to get stream role settings for. + /// Guild'p stream role settings + public static StreamRoleSettings GetStreamRoleSettings(this DbContext ctx, ulong guildId) + { + var conf = ctx.GuildConfigsForId(guildId, + set => set.Include(y => y.StreamRole) + .Include(y => y.StreamRole.Whitelist) + .Include(y => y.StreamRole.Blacklist)); + + if (conf.StreamRole is null) + conf.StreamRole = new(); + + return conf.StreamRole; + } + + private static IQueryable IncludeEverything(this DbSet configs) + => configs.AsQueryable() + .AsSplitQuery() + .Include(gc => gc.CommandCooldowns) + .Include(gc => gc.FollowedStreams) + .Include(gc => gc.StreamRole) + .Include(gc => gc.XpSettings) + .ThenInclude(x => x.ExclusionList) + .Include(gc => gc.DelMsgOnCmdChannels); + + public static IEnumerable GetAllGuildConfigs( + this DbSet configs, + IReadOnlyList availableGuilds) + => configs.IncludeEverything().AsNoTracking().Where(x => availableGuilds.Contains(x.GuildId)).ToList(); + + /// + /// Gets and creates if it doesn't exist a config for a guild. + /// + /// Context + /// Id of the guide + /// Use to manipulate the set however you want. Pass null to include everything + /// Config for the guild + public static GuildConfig GuildConfigsForId( + this DbContext ctx, + ulong guildId, + Func, IQueryable> includes) + { + GuildConfig config; + + if (includes is null) + config = ctx.Set().IncludeEverything().FirstOrDefault(c => c.GuildId == guildId); + else + { + var set = includes(ctx.Set()); + config = set.FirstOrDefault(c => c.GuildId == guildId); + } + + if (config is null) + { + ctx.Set().Add(config = new() + { + GuildId = guildId, + Permissions = Permissionv2.GetDefaultPermlist, + WarningsInitialized = true, + WarnPunishments = DefaultWarnPunishments + }); + ctx.SaveChanges(); + } + + if (!config.WarningsInitialized) + { + config.WarningsInitialized = true; + config.WarnPunishments = DefaultWarnPunishments; + } + + return config; + + // ctx.GuildConfigs + // .ToLinqToDBTable() + // .InsertOrUpdate(() => new() + // { + // GuildId = guildId, + // Permissions = Permissionv2.GetDefaultPermlist, + // WarningsInitialized = true, + // WarnPunishments = DefaultWarnPunishments + // }, + // _ => new(), + // () => new() + // { + // GuildId = guildId + // }); + // + // if(includes is null) + // return ctx.GuildConfigs + // .ToLinqToDBTable() + // .First(x => x.GuildId == guildId); + } + + public static LogSetting LogSettingsFor(this DbContext ctx, ulong guildId) + { + var logSetting = ctx.Set() + .AsQueryable() + .Include(x => x.LogIgnores) + .Where(x => x.GuildId == guildId) + .FirstOrDefault(); + + if (logSetting is null) + { + ctx.Set() + .Add(logSetting = new() + { + GuildId = guildId + }); + ctx.SaveChanges(); + } + + return logSetting; + } + + public static IEnumerable PermissionsForAll(this DbSet configs, List include) + { + var query = configs.AsQueryable().Where(x => include.Contains(x.GuildId)).Include(gc => gc.Permissions); + + return query.ToList(); + } + + public static GuildConfig GcWithPermissionsFor(this DbContext ctx, ulong guildId) + { + var config = ctx.Set().AsQueryable() + .Where(gc => gc.GuildId == guildId) + .Include(gc => gc.Permissions) + .FirstOrDefault(); + + if (config is null) // if there is no guildconfig, create new one + { + ctx.Set().Add(config = new() + { + GuildId = guildId, + Permissions = Permissionv2.GetDefaultPermlist + }); + ctx.SaveChanges(); + } + else if (config.Permissions is null || !config.Permissions.Any()) // if no perms, add default ones + { + config.Permissions = Permissionv2.GetDefaultPermlist; + ctx.SaveChanges(); + } + + return config; + } + + public static IEnumerable GetFollowedStreams(this DbSet configs) + => configs.AsQueryable().Include(x => x.FollowedStreams).SelectMany(gc => gc.FollowedStreams).ToArray(); + + public static IEnumerable GetFollowedStreams(this DbSet configs, List included) + => configs.AsQueryable() + .Where(gc => included.Contains(gc.GuildId)) + .Include(gc => gc.FollowedStreams) + .SelectMany(gc => gc.FollowedStreams) + .ToList(); + + public static void SetCleverbotEnabled(this DbSet configs, ulong id, bool cleverbotEnabled) + { + var conf = configs.FirstOrDefault(gc => gc.GuildId == id); + + if (conf is null) + return; + + conf.CleverbotEnabled = cleverbotEnabled; + } + + public static XpSettings XpSettingsFor(this DbContext ctx, ulong guildId) + { + var gc = ctx.GuildConfigsForId(guildId, + set => set.Include(x => x.XpSettings) + .ThenInclude(x => x.RoleRewards) + .Include(x => x.XpSettings) + .ThenInclude(x => x.CurrencyRewards) + .Include(x => x.XpSettings) + .ThenInclude(x => x.ExclusionList)); + + if (gc.XpSettings is null) + gc.XpSettings = new(); + + return gc.XpSettings; + } + + public static IEnumerable GetGeneratingChannels(this DbSet configs) + => configs.AsQueryable() + .Include(x => x.GenerateCurrencyChannelIds) + .Where(x => x.GenerateCurrencyChannelIds.Any()) + .SelectMany(x => x.GenerateCurrencyChannelIds) + .Select(x => new GeneratingChannel + { + ChannelId = x.ChannelId, + GuildId = x.GuildConfig.GuildId + }) + .ToArray(); + + public class GeneratingChannel + { + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/QuoteExtensions.cs b/src/EllieBot/Db/Extensions/QuoteExtensions.cs new file mode 100644 index 0000000..a213e6d --- /dev/null +++ b/src/EllieBot/Db/Extensions/QuoteExtensions.cs @@ -0,0 +1,53 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class QuoteExtensions +{ + public static IEnumerable GetForGuild(this DbSet quotes, ulong guildId) + => quotes.AsQueryable().Where(x => x.GuildId == guildId); + + public static IReadOnlyCollection GetGroup( + this DbSet quotes, + ulong guildId, + int page, + OrderType order) + { + var q = quotes.AsQueryable().Where(x => x.GuildId == guildId); + if (order == OrderType.Keyword) + q = q.OrderBy(x => x.Keyword); + else + q = q.OrderBy(x => x.Id); + + return q.Skip(15 * page).Take(15).ToArray(); + } + + public static async Task GetRandomQuoteByKeywordAsync( + this DbSet quotes, + ulong guildId, + string keyword) + { + return (await quotes.AsQueryable().Where(q => q.GuildId == guildId && q.Keyword == keyword).ToArrayAsync()) + .RandomOrDefault(); + } + + public static async Task SearchQuoteKeywordTextAsync( + this DbSet quotes, + ulong guildId, + string keyword, + string text) + { + return (await quotes.AsQueryable() + .Where(q => q.GuildId == guildId + && (keyword == null || q.Keyword == keyword) + && (EF.Functions.Like(q.Text.ToUpper(), $"%{text.ToUpper()}%") + || EF.Functions.Like(q.AuthorName, text))) + .ToArrayAsync()) + .RandomOrDefault(); + } + + public static void RemoveAllByKeyword(this DbSet quotes, ulong guildId, string keyword) + => quotes.RemoveRange(quotes.AsQueryable().Where(x => x.GuildId == guildId && x.Keyword.ToUpper() == keyword)); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/ReminderExtensions.cs b/src/EllieBot/Db/Extensions/ReminderExtensions.cs new file mode 100644 index 0000000..8a7d992 --- /dev/null +++ b/src/EllieBot/Db/Extensions/ReminderExtensions.cs @@ -0,0 +1,23 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class ReminderExtensions +{ + public static IEnumerable GetIncludedReminders( + this DbSet reminders, + IEnumerable guildIds) + => reminders.AsQueryable().Where(x => guildIds.Contains(x.ServerId) || x.ServerId == 0).ToList(); + + public static IEnumerable RemindersFor(this DbSet reminders, ulong userId, int page) + => reminders.AsQueryable().Where(x => x.UserId == userId).OrderBy(x => x.DateAdded).Skip(page * 10).Take(10); + + public static IEnumerable RemindersForServer(this DbSet reminders, ulong serverId, int page) + => reminders.AsQueryable() + .Where(x => x.ServerId == serverId) + .OrderBy(x => x.DateAdded) + .Skip(page * 10) + .Take(10); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/SelfAssignableRolesExtensions.cs b/src/EllieBot/Db/Extensions/SelfAssignableRolesExtensions.cs new file mode 100644 index 0000000..740a155 --- /dev/null +++ b/src/EllieBot/Db/Extensions/SelfAssignableRolesExtensions.cs @@ -0,0 +1,22 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class SelfAssignableRolesExtensions +{ + public static bool DeleteByGuildAndRoleId(this DbSet roles, ulong guildId, ulong roleId) + { + var role = roles.FirstOrDefault(s => s.GuildId == guildId && s.RoleId == roleId); + + if (role is null) + return false; + + roles.Remove(role); + return true; + } + + public static IReadOnlyCollection GetFromGuild(this DbSet roles, ulong guildId) + => roles.AsQueryable().Where(s => s.GuildId == guildId).ToArray(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/UserXpExtensions.cs b/src/EllieBot/Db/Extensions/UserXpExtensions.cs new file mode 100644 index 0000000..ab3e8bb --- /dev/null +++ b/src/EllieBot/Db/Extensions/UserXpExtensions.cs @@ -0,0 +1,70 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class UserXpExtensions +{ + public static UserXpStats GetOrCreateUserXpStats(this DbContext ctx, ulong guildId, ulong userId) + { + var usr = ctx.Set().FirstOrDefault(x => x.UserId == userId && x.GuildId == guildId); + + if (usr is null) + { + ctx.Add(usr = new() + { + Xp = 0, + UserId = userId, + NotifyOnLevelUp = XpNotificationLocation.None, + GuildId = guildId + }); + } + + return usr; + } + + public static async Task> GetUsersFor( + this DbSet xps, + ulong guildId, + int page) + => await xps.ToLinqToDBTable() + .Where(x => x.GuildId == guildId) + .OrderByDescending(x => x.Xp + x.AwardedXp) + .Skip(page * 9) + .Take(9) + .ToArrayAsyncLinqToDB(); + + public static async Task> GetTopUserXps(this DbSet xps, ulong guildId, int count) + => await xps.ToLinqToDBTable() + .Where(x => x.GuildId == guildId) + .OrderByDescending(x => x.Xp + x.AwardedXp) + .Take(count) + .ToListAsyncLinqToDB(); + + public static async Task GetUserGuildRanking(this DbSet xps, ulong userId, ulong guildId) + => await xps.ToLinqToDBTable() + .Where(x => x.GuildId == guildId + && x.Xp + x.AwardedXp + > xps.AsQueryable() + .Where(y => y.UserId == userId && y.GuildId == guildId) + .Select(y => y.Xp + y.AwardedXp) + .FirstOrDefault()) + .CountAsyncLinqToDB() + + 1; + + public static void ResetGuildUserXp(this DbSet xps, ulong userId, ulong guildId) + => xps.Delete(x => x.UserId == userId && x.GuildId == guildId); + + public static void ResetGuildXp(this DbSet xps, ulong guildId) + => xps.Delete(x => x.GuildId == guildId); + + public static async Task GetLevelDataFor(this ITable userXp, ulong guildId, ulong userId) + => await userXp + .Where(x => x.GuildId == guildId && x.UserId == userId) + .FirstOrDefaultAsyncLinqToDB() is UserXpStats uxs + ? new(uxs.Xp + uxs.AwardedXp) + : new(0); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/WarningExtensions.cs b/src/EllieBot/Db/Extensions/WarningExtensions.cs new file mode 100644 index 0000000..15f1039 --- /dev/null +++ b/src/EllieBot/Db/Extensions/WarningExtensions.cs @@ -0,0 +1,59 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class WarningExtensions +{ + public static Warning[] ForId(this DbSet warnings, ulong guildId, ulong userId) + { + var query = warnings.AsQueryable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .OrderByDescending(x => x.DateAdded); + + return query.ToArray(); + } + + public static bool Forgive( + this DbSet warnings, + ulong guildId, + ulong userId, + string mod, + int index) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + + var warn = warnings.AsQueryable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .OrderByDescending(x => x.DateAdded) + .Skip(index) + .FirstOrDefault(); + + if (warn is null || warn.Forgiven) + return false; + + warn.Forgiven = true; + warn.ForgivenBy = mod; + return true; + } + + public static async Task ForgiveAll( + this DbSet warnings, + ulong guildId, + ulong userId, + string mod) + => await warnings.AsQueryable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .ForEachAsync(x => + { + if (x.Forgiven != true) + { + x.Forgiven = true; + x.ForgivenBy = mod; + } + }); + + public static Warning[] GetForGuild(this DbSet warnings, ulong id) + => warnings.AsQueryable().Where(x => x.GuildId == id).ToArray(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Helpers/ActivityType.cs b/src/EllieBot/Db/Helpers/ActivityType.cs new file mode 100644 index 0000000..9c71e4b --- /dev/null +++ b/src/EllieBot/Db/Helpers/ActivityType.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Db; + +public enum DbActivityType +{ + /// The user is playing a game. + Playing, + + /// The user is streaming online. + Streaming, + + /// The user is listening to a song. + Listening, + + /// The user is watching some form of media. + Watching, + + /// The user has set a custom status. + CustomStatus, + + /// The user is competing in a game. + Competing, +} \ No newline at end of file diff --git a/src/EllieBot/Db/Helpers/GuildPerm.cs b/src/EllieBot/Db/Helpers/GuildPerm.cs new file mode 100644 index 0000000..60c4cde --- /dev/null +++ b/src/EllieBot/Db/Helpers/GuildPerm.cs @@ -0,0 +1,47 @@ +namespace EllieBot.Db; + +[Flags] +public enum GuildPerm : ulong +{ + CreateInstantInvite = 1, + KickMembers = 2, + BanMembers = 4, + Administrator = 8, + ManageChannels = 16, // 0x0000000000000010 + ManageGuild = 32, // 0x0000000000000020 + ViewGuildInsights = 524288, // 0x0000000000080000 + AddReactions = 64, // 0x0000000000000040 + ViewAuditLog = 128, // 0x0000000000000080 + ViewChannel = 1024, // 0x0000000000000400 + SendMessages = 2048, // 0x0000000000000800 + SendTTSMessages = 4096, // 0x0000000000001000 + ManageMessages = 8192, // 0x0000000000002000 + EmbedLinks = 16384, // 0x0000000000004000 + AttachFiles = 32768, // 0x0000000000008000 + ReadMessageHistory = 65536, // 0x0000000000010000 + MentionEveryone = 131072, // 0x0000000000020000 + UseExternalEmojis = 262144, // 0x0000000000040000 + Connect = 1048576, // 0x0000000000100000 + Speak = 2097152, // 0x0000000000200000 + MuteMembers = 4194304, // 0x0000000000400000 + DeafenMembers = 8388608, // 0x0000000000800000 + MoveMembers = 16777216, // 0x0000000001000000 + UseVAD = 33554432, // 0x0000000002000000 + PrioritySpeaker = 256, // 0x0000000000000100 + Stream = 512, // 0x0000000000000200 + ChangeNickname = 67108864, // 0x0000000004000000 + ManageNicknames = 134217728, // 0x0000000008000000 + ManageRoles = 268435456, // 0x0000000010000000 + ManageWebhooks = 536870912, // 0x0000000020000000 + ManageEmojisAndStickers = 1073741824, // 0x0000000040000000 + UseApplicationCommands = 2147483648, // 0x0000000080000000 + RequestToSpeak = 4294967296, // 0x0000000100000000 + ManageEvents = 8589934592, // 0x0000000200000000 + ManageThreads = 17179869184, // 0x0000000400000000 + CreatePublicThreads = 34359738368, // 0x0000000800000000 + CreatePrivateThreads = 68719476736, // 0x0000001000000000 + UseExternalStickers = 137438953472, // 0x0000002000000000 + SendMessagesInThreads = 274877906944, // 0x0000004000000000 + StartEmbeddedActivities = 549755813888, // 0x0000008000000000 + ModerateMembers = 1099511627776, // 0x0000010000000000 +} \ No newline at end of file diff --git a/src/EllieBot/Db/LevelStats.cs b/src/EllieBot/Db/LevelStats.cs new file mode 100644 index 0000000..0f3e02c --- /dev/null +++ b/src/EllieBot/Db/LevelStats.cs @@ -0,0 +1,40 @@ +#nullable disable +namespace EllieBot.Db; + +public readonly struct LevelStats +{ + public const int XP_REQUIRED_LVL_1 = 36; + + public long Level { get; } + public long LevelXp { get; } + public long RequiredXp { get; } + public long TotalXp { get; } + + public LevelStats(long xp) + { + if (xp < 0) + xp = 0; + + TotalXp = xp; + + const int baseXp = XP_REQUIRED_LVL_1; + + var required = baseXp; + var totalXp = 0; + var lvl = 1; + while (true) + { + required = (int)(baseXp + (baseXp / 4.0 * (lvl - 1))); + + if (required + totalXp > xp) + break; + + totalXp += required; + lvl++; + } + + Level = lvl - 1; + LevelXp = xp - totalXp; + RequiredXp = required; + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/AutoCommand.cs b/src/EllieBot/Db/Models/AutoCommand.cs new file mode 100644 index 0000000..f412448 --- /dev/null +++ b/src/EllieBot/Db/Models/AutoCommand.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class AutoCommand : DbEntity +{ + public string CommandText { get; set; } + public ulong ChannelId { get; set; } + public string ChannelName { get; set; } + public ulong? GuildId { get; set; } + public string GuildName { get; set; } + public ulong? VoiceChannelId { get; set; } + public string VoiceChannelName { get; set; } + public int Interval { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/AutoPublishChannel.cs b/src/EllieBot/Db/Models/AutoPublishChannel.cs new file mode 100644 index 0000000..c632776 --- /dev/null +++ b/src/EllieBot/Db/Models/AutoPublishChannel.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Db.Models; + +public class AutoPublishChannel : DbEntity +{ + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/AutoTranslateChannel.cs b/src/EllieBot/Db/Models/AutoTranslateChannel.cs new file mode 100644 index 0000000..33423c1 --- /dev/null +++ b/src/EllieBot/Db/Models/AutoTranslateChannel.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class AutoTranslateChannel : DbEntity +{ + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public bool AutoDelete { get; set; } + public IList Users { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/AutoTranslateUser.cs b/src/EllieBot/Db/Models/AutoTranslateUser.cs new file mode 100644 index 0000000..459d58b --- /dev/null +++ b/src/EllieBot/Db/Models/AutoTranslateUser.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class AutoTranslateUser : DbEntity +{ + public int ChannelId { get; set; } + public AutoTranslateChannel Channel { get; set; } + public ulong UserId { get; set; } + public string Source { get; set; } + public string Target { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/BlacklistEntry.cs b/src/EllieBot/Db/Models/BlacklistEntry.cs new file mode 100644 index 0000000..d457e8a --- /dev/null +++ b/src/EllieBot/Db/Models/BlacklistEntry.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class BlacklistEntry : DbEntity +{ + public ulong ItemId { get; set; } + public BlacklistType Type { get; set; } +} + +public enum BlacklistType +{ + Server, + Channel, + User +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/CommandAlias.cs b/src/EllieBot/Db/Models/CommandAlias.cs new file mode 100644 index 0000000..28f614a --- /dev/null +++ b/src/EllieBot/Db/Models/CommandAlias.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class CommandAlias : DbEntity +{ + public string Trigger { get; set; } + public string Mapping { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/CommandCooldown.cs b/src/EllieBot/Db/Models/CommandCooldown.cs new file mode 100644 index 0000000..e12ef9c --- /dev/null +++ b/src/EllieBot/Db/Models/CommandCooldown.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class CommandCooldown : DbEntity +{ + public int Seconds { get; set; } + public string CommandName { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/CurrencyTransaction.cs b/src/EllieBot/Db/Models/CurrencyTransaction.cs new file mode 100644 index 0000000..8b9f6a6 --- /dev/null +++ b/src/EllieBot/Db/Models/CurrencyTransaction.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class CurrencyTransaction : DbEntity +{ + public long Amount { get; set; } + public string Note { get; set; } + public ulong UserId { get; set; } + public string Type { get; set; } + public string Extra { get; set; } + public ulong? OtherId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/DbEntity.cs b/src/EllieBot/Db/Models/DbEntity.cs new file mode 100644 index 0000000..0ed8388 --- /dev/null +++ b/src/EllieBot/Db/Models/DbEntity.cs @@ -0,0 +1,12 @@ +#nullable disable +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class DbEntity +{ + [Key] + public int Id { get; set; } + + public DateTime? DateAdded { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs b/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs new file mode 100644 index 0000000..dd70ed6 --- /dev/null +++ b/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class DelMsgOnCmdChannel : DbEntity +{ + public int GuildConfigId { get; set; } + + public ulong ChannelId { get; set; } + public bool State { get; set; } + + public override int GetHashCode() + => ChannelId.GetHashCode(); + + public override bool Equals(object obj) + => obj is DelMsgOnCmdChannel x && x.ChannelId == ChannelId; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/DiscordPemOverride.cs b/src/EllieBot/Db/Models/DiscordPemOverride.cs new file mode 100644 index 0000000..b9ecd24 --- /dev/null +++ b/src/EllieBot/Db/Models/DiscordPemOverride.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class DiscordPermOverride : DbEntity +{ + public GuildPerm Perm { get; set; } + + public ulong? GuildId { get; set; } + public string Command { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/DiscordUser.cs b/src/EllieBot/Db/Models/DiscordUser.cs new file mode 100644 index 0000000..69eecb6 --- /dev/null +++ b/src/EllieBot/Db/Models/DiscordUser.cs @@ -0,0 +1,35 @@ +#nullable disable +namespace EllieBot.Db.Models; + + +// FUTURE remove LastLevelUp from here and UserXpStats +public class DiscordUser : DbEntity +{ + public ulong UserId { get; set; } + public string Username { get; set; } + public string Discriminator { get; set; } + public string AvatarId { get; set; } + + public int? ClubId { get; set; } + public ClubInfo Club { get; set; } + public bool IsClubAdmin { get; set; } + + public long TotalXp { get; set; } + public XpNotificationLocation NotifyOnLevelUp { get; set; } + + public long CurrencyAmount { get; set; } + + public override bool Equals(object obj) + => obj is DiscordUser du ? du.UserId == UserId : false; + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override string ToString() + { + if (string.IsNullOrWhiteSpace(Discriminator) || Discriminator == "0000") + return Username; + + return Username + "#" + Discriminator; + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Event.cs b/src/EllieBot/Db/Models/Event.cs new file mode 100644 index 0000000..63202f6 --- /dev/null +++ b/src/EllieBot/Db/Models/Event.cs @@ -0,0 +1,49 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class CurrencyEvent +{ + public enum Type + { + Reaction, + + GameStatus + //NotRaid, + } + + public ulong ServerId { get; set; } + public ulong ChannelId { get; set; } + public ulong MessageId { get; set; } + public Type EventType { get; set; } + + /// + /// Amount of currency that the user will be rewarded. + /// + public long Amount { get; set; } + + /// + /// Maximum amount of currency that can be handed out. + /// + public long PotSize { get; set; } + + public List AwardedUsers { get; set; } + + /// + /// Used as extra data storage for events which need it. + /// + public ulong ExtraId { get; set; } + + /// + /// May be used for some future event. + /// + public ulong ExtraId2 { get; set; } + + /// + /// May be used for some future event. + /// + public string ExtraString { get; set; } +} + +public class AwardedUser +{ +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/FeedSub.cs b/src/EllieBot/Db/Models/FeedSub.cs new file mode 100644 index 0000000..f257f96 --- /dev/null +++ b/src/EllieBot/Db/Models/FeedSub.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FeedSub : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + public ulong ChannelId { get; set; } + public string Url { get; set; } + + public string Message { get; set; } + + public override int GetHashCode() + => Url.GetHashCode(StringComparison.InvariantCulture) ^ GuildConfigId.GetHashCode(); + + public override bool Equals(object obj) + => obj is FeedSub s && s.Url.ToLower() == Url.ToLower() && s.GuildConfigId == GuildConfigId; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/FollowedStream.cs b/src/EllieBot/Db/Models/FollowedStream.cs new file mode 100644 index 0000000..183e0ab --- /dev/null +++ b/src/EllieBot/Db/Models/FollowedStream.cs @@ -0,0 +1,33 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FollowedStream : DbEntity +{ + public enum FType + { + Twitch = 0, + Picarto = 3, + Youtube = 4, + Facebook = 5, + Trovo = 6 + } + + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public string Username { get; set; } + public FType Type { get; set; } + public string Message { get; set; } + + protected bool Equals(FollowedStream other) + => ChannelId == other.ChannelId + && Username.Trim().ToUpperInvariant() == other.Username.Trim().ToUpperInvariant() + && Type == other.Type; + + public override int GetHashCode() + => HashCode.Combine(ChannelId, Username, (int)Type); + + public override bool Equals(object obj) + => obj is FollowedStream fs && Equals(fs); + + +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GCChannelId.cs b/src/EllieBot/Db/Models/GCChannelId.cs new file mode 100644 index 0000000..c6f922b --- /dev/null +++ b/src/EllieBot/Db/Models/GCChannelId.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class GCChannelId : DbEntity +{ + public GuildConfig GuildConfig { get; set; } + public ulong ChannelId { get; set; } + + public override bool Equals(object obj) + => obj is GCChannelId gc && gc.ChannelId == ChannelId; + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GamblingStats.cs b/src/EllieBot/Db/Models/GamblingStats.cs new file mode 100644 index 0000000..3c91a3b --- /dev/null +++ b/src/EllieBot/Db/Models/GamblingStats.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class GamblingStats : DbEntity +{ + public string Feature { get; set; } + public decimal Bet { get; set; } + public decimal PaidOut { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GroupName.cs b/src/EllieBot/Db/Models/GroupName.cs new file mode 100644 index 0000000..3e29b31 --- /dev/null +++ b/src/EllieBot/Db/Models/GroupName.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class GroupName : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + public int Number { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GuildColors.cs b/src/EllieBot/Db/Models/GuildColors.cs new file mode 100644 index 0000000..efd5fc1 --- /dev/null +++ b/src/EllieBot/Db/Models/GuildColors.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class GuildColors +{ + [Key] + public ulong GuildId { get; set; } + + [Length(0, 9)] + public string? OkColor { get; set; } + + [Length(0, 9)] + public string? ErrorColor { get; set; } + + [Length(0, 9)] + public string? PendingColor { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GuildConfig.cs b/src/EllieBot/Db/Models/GuildConfig.cs new file mode 100644 index 0000000..a88d3c0 --- /dev/null +++ b/src/EllieBot/Db/Models/GuildConfig.cs @@ -0,0 +1,108 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class GuildConfig : DbEntity +{ + // public bool Keep { get; set; } + public ulong GuildId { get; set; } + + public string Prefix { get; set; } + + public bool DeleteMessageOnCommand { get; set; } + public HashSet DelMsgOnCmdChannels { get; set; } = new(); + + public string AutoAssignRoleIds { get; set; } + + //greet stuff + public int AutoDeleteGreetMessagesTimer { get; set; } = 30; + public int AutoDeleteByeMessagesTimer { get; set; } = 30; + + public ulong GreetMessageChannelId { get; set; } + public ulong ByeMessageChannelId { get; set; } + + public bool SendDmGreetMessage { get; set; } + public string DmGreetMessageText { get; set; } = "Welcome to the %server% server, %user%!"; + + public bool SendChannelGreetMessage { get; set; } + public string ChannelGreetMessageText { get; set; } = "Welcome to the %server% server, %user%!"; + + public bool SendChannelByeMessage { get; set; } + public string ChannelByeMessageText { get; set; } = "%user% has left!"; + + //self assignable roles + public bool ExclusiveSelfAssignedRoles { get; set; } + public bool AutoDeleteSelfAssignedRoleMessages { get; set; } + + //stream notifications + public HashSet FollowedStreams { get; set; } = new(); + + //currencyGeneration + public HashSet GenerateCurrencyChannelIds { get; set; } = new(); + + public List Permissions { get; set; } + public bool VerbosePermissions { get; set; } = true; + public string PermissionRole { get; set; } + + public HashSet CommandCooldowns { get; set; } = new(); + + //filtering + public bool FilterInvites { get; set; } + public bool FilterLinks { get; set; } + public HashSet FilterInvitesChannelIds { get; set; } = new(); + public HashSet FilterLinksChannelIds { get; set; } = new(); + + //public bool FilterLinks { get; set; } + //public HashSet FilterLinksChannels { get; set; } = new HashSet(); + + public bool FilterWords { get; set; } + public HashSet FilteredWords { get; set; } = new(); + public HashSet FilterWordsChannelIds { get; set; } = new(); + + public HashSet MutedUsers { get; set; } = new(); + + public string MuteRoleName { get; set; } + public bool CleverbotEnabled { get; set; } + + public AntiRaidSetting AntiRaidSetting { get; set; } + public AntiSpamSetting AntiSpamSetting { get; set; } + public AntiAltSetting AntiAltSetting { get; set; } + + public string Locale { get; set; } + public string TimeZoneId { get; set; } + + public HashSet UnmuteTimers { get; set; } = new(); + public HashSet UnbanTimer { get; set; } = new(); + public HashSet UnroleTimer { get; set; } = new(); + public HashSet VcRoleInfos { get; set; } + public HashSet CommandAliases { get; set; } = new(); + public List WarnPunishments { get; set; } = new(); + public bool WarningsInitialized { get; set; } + public HashSet SlowmodeIgnoredUsers { get; set; } + public HashSet SlowmodeIgnoredRoles { get; set; } + + public List ShopEntries { get; set; } + public ulong? GameVoiceChannel { get; set; } + public bool VerboseErrors { get; set; } = true; + + public StreamRoleSettings StreamRole { get; set; } + + public XpSettings XpSettings { get; set; } + public List FeedSubs { get; set; } = new(); + public bool NotifyStreamOffline { get; set; } + public bool DeleteStreamOnlineMessage { get; set; } + public List SelfAssignableRoleGroupNames { get; set; } + public int WarnExpireHours { get; set; } + public WarnExpireAction WarnExpireAction { get; set; } = WarnExpireAction.Clear; + + public bool DisableGlobalExpressions { get; set; } = false; + + #region Boost Message + + public bool SendBoostMessage { get; set; } + public string BoostMessage { get; set; } = "%user% just boosted this server!"; + public ulong BoostMessageChannelId { get; set; } + public int BoostMessageDeleteAfter { get; set; } + public bool StickyRoles { get; set; } + + #endregion +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/IgnoredLogItem.cs b/src/EllieBot/Db/Models/IgnoredLogItem.cs new file mode 100644 index 0000000..7424d84 --- /dev/null +++ b/src/EllieBot/Db/Models/IgnoredLogItem.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class IgnoredLogItem : DbEntity +{ + public int LogSettingId { get; set; } + public LogSetting LogSetting { get; set; } + public ulong LogItemId { get; set; } + public IgnoredItemType ItemType { get; set; } +} + +public enum IgnoredItemType +{ + Channel, + User +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/ImageOnlyChannel.cs b/src/EllieBot/Db/Models/ImageOnlyChannel.cs new file mode 100644 index 0000000..01d01fa --- /dev/null +++ b/src/EllieBot/Db/Models/ImageOnlyChannel.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class ImageOnlyChannel : DbEntity +{ + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public OnlyChannelType Type { get; set; } +} + +public enum OnlyChannelType +{ + Image, + Link +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/LogSetting.cs b/src/EllieBot/Db/Models/LogSetting.cs new file mode 100644 index 0000000..916b1b8 --- /dev/null +++ b/src/EllieBot/Db/Models/LogSetting.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class LogSetting : DbEntity +{ + public List LogIgnores { get; set; } = new(); + + public ulong GuildId { get; set; } + public ulong? LogOtherId { get; set; } + public ulong? MessageUpdatedId { get; set; } + public ulong? MessageDeletedId { get; set; } + + public ulong? UserJoinedId { get; set; } + public ulong? UserLeftId { get; set; } + public ulong? UserBannedId { get; set; } + public ulong? UserUnbannedId { get; set; } + public ulong? UserUpdatedId { get; set; } + + public ulong? ChannelCreatedId { get; set; } + public ulong? ChannelDestroyedId { get; set; } + public ulong? ChannelUpdatedId { get; set; } + + + public ulong? ThreadDeletedId { get; set; } + public ulong? ThreadCreatedId { get; set; } + + public ulong? UserMutedId { get; set; } + + //userpresence + public ulong? LogUserPresenceId { get; set; } + + //voicepresence + + public ulong? LogVoicePresenceId { get; set; } + + public ulong? LogVoicePresenceTTSId { get; set; } + public ulong? LogWarnsId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Permission.cs b/src/EllieBot/Db/Models/Permission.cs new file mode 100644 index 0000000..b926f92 --- /dev/null +++ b/src/EllieBot/Db/Models/Permission.cs @@ -0,0 +1,52 @@ +#nullable disable +using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics; + +namespace EllieBot.Db.Models; + +[DebuggerDisplay("{PrimaryTarget}{SecondaryTarget} {SecondaryTargetName} {State} {PrimaryTargetId}")] +public class Permissionv2 : DbEntity, IIndexed +{ + public int? GuildConfigId { get; set; } + public int Index { get; set; } + + public PrimaryPermissionType PrimaryTarget { get; set; } + public ulong PrimaryTargetId { get; set; } + + public SecondaryPermissionType SecondaryTarget { get; set; } + public string SecondaryTargetName { get; set; } + + public bool IsCustomCommand { get; set; } + + public bool State { get; set; } + + [NotMapped] + public static Permissionv2 AllowAllPerm + => new() + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = true, + Index = 0 + }; + + public static List GetDefaultPermlist + => [AllowAllPerm]; +} + +public enum PrimaryPermissionType +{ + User, + Channel, + Role, + Server +} + +public enum SecondaryPermissionType +{ + Module, + Command, + AllModules +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/PlantedCurrency.cs b/src/EllieBot/Db/Models/PlantedCurrency.cs new file mode 100644 index 0000000..7e640a5 --- /dev/null +++ b/src/EllieBot/Db/Models/PlantedCurrency.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class PlantedCurrency : DbEntity +{ + public long Amount { get; set; } + public string Password { get; set; } + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public ulong UserId { get; set; } + public ulong MessageId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/PlaylistSong.cs b/src/EllieBot/Db/Models/PlaylistSong.cs new file mode 100644 index 0000000..ccd9312 --- /dev/null +++ b/src/EllieBot/Db/Models/PlaylistSong.cs @@ -0,0 +1,18 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class PlaylistSong : DbEntity +{ + public string Provider { get; set; } + public MusicType ProviderType { get; set; } + public string Title { get; set; } + public string Uri { get; set; } + public string Query { get; set; } +} + +public enum MusicType +{ + Radio, + YouTube, + Local, +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Reminder.cs b/src/EllieBot/Db/Models/Reminder.cs new file mode 100644 index 0000000..046615a --- /dev/null +++ b/src/EllieBot/Db/Models/Reminder.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class Reminder : DbEntity +{ + public DateTime When { get; set; } + public ulong ChannelId { get; set; } + public ulong ServerId { get; set; } + public ulong UserId { get; set; } + public string Message { get; set; } + public bool IsPrivate { get; set; } + public ReminderType Type { get; set; } +} + +public enum ReminderType +{ + User, + Timely +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Repeater.cs b/src/EllieBot/Db/Models/Repeater.cs new file mode 100644 index 0000000..d0ef69e --- /dev/null +++ b/src/EllieBot/Db/Models/Repeater.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class Repeater +{ + public int Id { get; set; } + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public ulong? LastMessageId { get; set; } + public string Message { get; set; } + public TimeSpan Interval { get; set; } + public TimeSpan? StartTimeOfDay { get; set; } + public bool NoRedundant { get; set; } + public DateTime DateAdded { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/RotatingPlayingStatus.cs b/src/EllieBot/Db/Models/RotatingPlayingStatus.cs new file mode 100644 index 0000000..6cf5cc4 --- /dev/null +++ b/src/EllieBot/Db/Models/RotatingPlayingStatus.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class RotatingPlayingStatus : DbEntity +{ + public string Status { get; set; } + public DbActivityType Type { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/ShopEntry.cs b/src/EllieBot/Db/Models/ShopEntry.cs new file mode 100644 index 0000000..a21c7e6 --- /dev/null +++ b/src/EllieBot/Db/Models/ShopEntry.cs @@ -0,0 +1,46 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public enum ShopEntryType +{ + Role, + + List, + Command +} + +public class ShopEntry : DbEntity, IIndexed +{ + public int Index { get; set; } + public int Price { get; set; } + public string Name { get; set; } + public ulong AuthorId { get; set; } + + public ShopEntryType Type { get; set; } + + //role + public string RoleName { get; set; } + public ulong RoleId { get; set; } + + //list + public HashSet Items { get; set; } = new(); + public ulong? RoleRequirement { get; set; } + + // command + public string Command { get; set; } +} + +public class ShopEntryItem : DbEntity +{ + public string Text { get; set; } + + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + return ((ShopEntryItem)obj).Text == Text; + } + + public override int GetHashCode() + => Text.GetHashCode(StringComparison.InvariantCulture); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/StreamOnlineMessage.cs b/src/EllieBot/Db/Models/StreamOnlineMessage.cs new file mode 100644 index 0000000..c6443a6 --- /dev/null +++ b/src/EllieBot/Db/Models/StreamOnlineMessage.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class StreamOnlineMessage : DbEntity +{ + public ulong ChannelId { get; set; } + public ulong MessageId { get; set; } + + public FollowedStream.FType Type { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/StreamRoleSettings.cs b/src/EllieBot/Db/Models/StreamRoleSettings.cs new file mode 100644 index 0000000..bd36b4c --- /dev/null +++ b/src/EllieBot/Db/Models/StreamRoleSettings.cs @@ -0,0 +1,74 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class StreamRoleSettings : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + /// + /// Whether the feature is enabled in the guild. + /// + public bool Enabled { get; set; } + + /// + /// Id of the role to give to the users in the role 'FromRole' when they start streaming + /// + public ulong AddRoleId { get; set; } + + /// + /// Id of the role whose users are eligible to get the 'AddRole' + /// + public ulong FromRoleId { get; set; } + + /// + /// If set, feature will only apply to users who have this keyword in their streaming status. + /// + public string Keyword { get; set; } + + /// + /// A collection of whitelisted users' IDs. Whitelisted users don't require 'keyword' in + /// order to get the stream role. + /// + public HashSet Whitelist { get; set; } = new(); + + /// + /// A collection of blacklisted users' IDs. Blacklisted useres will never get the stream role. + /// + public HashSet Blacklist { get; set; } = new(); +} + +public class StreamRoleBlacklistedUser : DbEntity +{ + public int StreamRoleSettingsId { get; set; } + public StreamRoleSettings StreamRoleSettings { get; set; } + + public ulong UserId { get; set; } + public string Username { get; set; } + + public override bool Equals(object obj) + { + if (obj is not StreamRoleBlacklistedUser x) + return false; + + return x.UserId == UserId; + } + + public override int GetHashCode() + => UserId.GetHashCode(); +} + +public class StreamRoleWhitelistedUser : DbEntity +{ + public int StreamRoleSettingsId { get; set; } + public StreamRoleSettings StreamRoleSettings { get; set; } + + public ulong UserId { get; set; } + public string Username { get; set; } + + public override bool Equals(object obj) + => obj is StreamRoleWhitelistedUser x ? x.UserId == UserId : false; + + public override int GetHashCode() + => UserId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/VcRoleInfo.cs b/src/EllieBot/Db/Models/VcRoleInfo.cs new file mode 100644 index 0000000..bb28450 --- /dev/null +++ b/src/EllieBot/Db/Models/VcRoleInfo.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class VcRoleInfo : DbEntity +{ + public ulong VoiceChannelId { get; set; } + public ulong RoleId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Waifu.cs b/src/EllieBot/Db/Models/Waifu.cs new file mode 100644 index 0000000..2583afb --- /dev/null +++ b/src/EllieBot/Db/Models/Waifu.cs @@ -0,0 +1,75 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Services.Database.Models; + +public class WaifuInfo : DbEntity +{ + public int WaifuId { get; set; } + public DiscordUser Waifu { get; set; } + + public int? ClaimerId { get; set; } + public DiscordUser Claimer { get; set; } + + public int? AffinityId { get; set; } + public DiscordUser Affinity { get; set; } + + public long Price { get; set; } + public List Items { get; set; } = new(); + + public override string ToString() + { + var status = string.Empty; + + var waifuUsername = Waifu.ToString().TrimTo(20); + var claimer = Claimer?.ToString().TrimTo(20) + ?? "no one"; + + var affinity = Affinity?.ToString().TrimTo(20); + + if (AffinityId is null) + status = $"... but {waifuUsername}'s heart is empty"; + else if (AffinityId == ClaimerId) + status = $"... and {waifuUsername} likes {claimer} too <3"; + else + { + status = + $"... but {waifuUsername}'s heart belongs to {affinity}"; + } + + return $"**{waifuUsername}** - claimed by **{claimer}**\n\t{status}"; + } +} + +public class WaifuLbResult +{ + public string Username { get; set; } + public string Discrim { get; set; } + + public string Claimer { get; set; } + public string ClaimerDiscrim { get; set; } + + public string Affinity { get; set; } + public string AffinityDiscrim { get; set; } + + public long Price { get; set; } + + public override string ToString() + { + var claimer = "no one"; + var status = string.Empty; + + var waifuUsername = Username.TrimTo(20); + var claimerUsername = Claimer?.TrimTo(20); + + if (Claimer is not null) + claimer = $"{claimerUsername}#{ClaimerDiscrim}"; + if (Affinity is null) + status = $"... but {waifuUsername}'s heart is empty"; + else if (Affinity + AffinityDiscrim == Claimer + ClaimerDiscrim) + status = $"... and {waifuUsername} likes {claimerUsername} too <3"; + else + status = $"... but {waifuUsername}'s heart belongs to {Affinity.TrimTo(20)}#{AffinityDiscrim}"; + return $"**{waifuUsername}#{Discrim}** - claimed by **{claimer}**\n\t{status}"; + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/anti/AntiAltSetting.cs b/src/EllieBot/Db/Models/anti/AntiAltSetting.cs new file mode 100644 index 0000000..cb0da3c --- /dev/null +++ b/src/EllieBot/Db/Models/anti/AntiAltSetting.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Db.Models; + +public class AntiAltSetting +{ + public int GuildConfigId { get; set; } + + public int Id { get; set; } + public TimeSpan MinAge { get; set; } + public PunishmentAction Action { get; set; } + public int ActionDurationMinutes { get; set; } + public ulong? RoleId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs b/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs new file mode 100644 index 0000000..b5e5f67 --- /dev/null +++ b/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs @@ -0,0 +1,20 @@ +#nullable disable +using System.ComponentModel.DataAnnotations.Schema; + +namespace EllieBot.Db.Models; + + +public class AntiRaidSetting : DbEntity +{ + public int GuildConfigId { get; set; } + + public int UserThreshold { get; set; } + public int Seconds { get; set; } + public PunishmentAction Action { get; set; } + + /// + /// Duration of the punishment, in minutes. This works only for supported Actions, like: + /// Mute, Chatmute, Voicemute, etc... + /// + public int PunishDuration { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/anti/AntiSpamIgnore.cs b/src/EllieBot/Db/Models/anti/AntiSpamIgnore.cs new file mode 100644 index 0000000..a3cd623 --- /dev/null +++ b/src/EllieBot/Db/Models/anti/AntiSpamIgnore.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Db.Models; + +public class AntiSpamIgnore : DbEntity +{ + public ulong ChannelId { get; set; } + + public override int GetHashCode() + => ChannelId.GetHashCode(); + + public override bool Equals(object? obj) + => obj is AntiSpamIgnore inst && inst.ChannelId == ChannelId; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs b/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs new file mode 100644 index 0000000..7e19253 --- /dev/null +++ b/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public class AntiSpamSetting : DbEntity +{ + public int GuildConfigId { get; set; } + + public PunishmentAction Action { get; set; } + public int MessageThreshold { get; set; } = 3; + public int MuteTime { get; set; } + public ulong? RoleId { get; set; } + public HashSet IgnoredChannels { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/club/ClubInfo.cs b/src/EllieBot/Db/Models/club/ClubInfo.cs new file mode 100644 index 0000000..a7bc16f --- /dev/null +++ b/src/EllieBot/Db/Models/club/ClubInfo.cs @@ -0,0 +1,41 @@ +#nullable disable +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class ClubInfo : DbEntity +{ + [MaxLength(20)] + public string Name { get; set; } + public string Description { get; set; } + public string ImageUrl { get; set; } = string.Empty; + + public int Xp { get; set; } = 0; + public int? OwnerId { get; set; } + public DiscordUser Owner { get; set; } + + public List Members { get; set; } = new(); + public List Applicants { get; set; } = new(); + public List Bans { get; set; } = new(); + + public override string ToString() + => Name; +} + +public class ClubApplicants +{ + public int ClubId { get; set; } + public ClubInfo Club { get; set; } + + public int UserId { get; set; } + public DiscordUser User { get; set; } +} + +public class ClubBans +{ + public int ClubId { get; set; } + public ClubInfo Club { get; set; } + + public int UserId { get; set; } + public DiscordUser User { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/currency/BankUser.cs b/src/EllieBot/Db/Models/currency/BankUser.cs new file mode 100644 index 0000000..b62b49d --- /dev/null +++ b/src/EllieBot/Db/Models/currency/BankUser.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Db.Models; + +public class BankUser : DbEntity +{ + public ulong UserId { get; set; } + public long Balance { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/expr/EllieExpression.cs b/src/EllieBot/Db/Models/expr/EllieExpression.cs new file mode 100644 index 0000000..53eef8b --- /dev/null +++ b/src/EllieBot/Db/Models/expr/EllieExpression.cs @@ -0,0 +1,27 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class EllieExpression : DbEntity +{ + public ulong? GuildId { get; set; } + public string Response { get; set; } + public string Trigger { get; set; } + + public bool AutoDeleteTrigger { get; set; } + public bool DmResponse { get; set; } + public bool ContainsAnywhere { get; set; } + public bool AllowTarget { get; set; } + public string Reactions { get; set; } + + public string[] GetReactions() + => string.IsNullOrWhiteSpace(Reactions) ? Array.Empty() : Reactions.Split("@@@"); + + public bool IsGlobal() + => GuildId is null or 0; +} + +public class ReactionResponse : DbEntity +{ + public bool OwnerOnly { get; set; } + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/expr/Quote.cs b/src/EllieBot/Db/Models/expr/Quote.cs new file mode 100644 index 0000000..62f57d7 --- /dev/null +++ b/src/EllieBot/Db/Models/expr/Quote.cs @@ -0,0 +1,26 @@ +#nullable disable +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class Quote : DbEntity +{ + public ulong GuildId { get; set; } + + [Required] + public string Keyword { get; set; } + + [Required] + public string AuthorName { get; set; } + + public ulong AuthorId { get; set; } + + [Required] + public string Text { get; set; } +} + +public enum OrderType +{ + Id = -1, + Keyword = -2 +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/filter/FilterChannelId.cs b/src/EllieBot/Db/Models/filter/FilterChannelId.cs new file mode 100644 index 0000000..eb1d965 --- /dev/null +++ b/src/EllieBot/Db/Models/filter/FilterChannelId.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FilterChannelId : DbEntity +{ + public ulong ChannelId { get; set; } + + public bool Equals(FilterChannelId other) + => ChannelId == other.ChannelId; + + public override bool Equals(object obj) + => obj is FilterChannelId fci && Equals(fci); + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} diff --git a/src/EllieBot/Db/Models/filter/FilterLinksChannelId.cs b/src/EllieBot/Db/Models/filter/FilterLinksChannelId.cs new file mode 100644 index 0000000..50aca96 --- /dev/null +++ b/src/EllieBot/Db/Models/filter/FilterLinksChannelId.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FilterLinksChannelId : DbEntity +{ + public ulong ChannelId { get; set; } + + public override bool Equals(object obj) + => obj is FilterLinksChannelId f && f.ChannelId == ChannelId; + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/filter/FilterWordsChannelId.cs b/src/EllieBot/Db/Models/filter/FilterWordsChannelId.cs new file mode 100644 index 0000000..6921032 --- /dev/null +++ b/src/EllieBot/Db/Models/filter/FilterWordsChannelId.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FilterWordsChannelId : DbEntity +{ + public int? GuildConfigId { get; set; } + public ulong ChannelId { get; set; } + + public bool Equals(FilterWordsChannelId other) + => ChannelId == other.ChannelId; + + public override bool Equals(object obj) + => obj is FilterWordsChannelId fci && Equals(fci); + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/filter/FilteredWord.cs b/src/EllieBot/Db/Models/filter/FilteredWord.cs new file mode 100644 index 0000000..de66d7a --- /dev/null +++ b/src/EllieBot/Db/Models/filter/FilteredWord.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FilteredWord : DbEntity +{ + public string Word { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/giveaway/GiveawayModel.cs b/src/EllieBot/Db/Models/giveaway/GiveawayModel.cs new file mode 100644 index 0000000..ca077b2 --- /dev/null +++ b/src/EllieBot/Db/Models/giveaway/GiveawayModel.cs @@ -0,0 +1,14 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public sealed class GiveawayModel +{ + public int Id { get; set; } + public ulong GuildId { get; set; } + public ulong MessageId { get; set; } + public ulong ChannelId { get; set; } + public string Message { get; set; } + + public IList Participants { get; set; } = new List(); + public DateTime EndsAt { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/giveaway/GiveawayUser.cs b/src/EllieBot/Db/Models/giveaway/GiveawayUser.cs new file mode 100644 index 0000000..a8b964e --- /dev/null +++ b/src/EllieBot/Db/Models/giveaway/GiveawayUser.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public sealed class GiveawayUser +{ + public int Id { get; set; } + public int GiveawayId { get; set; } + public ulong UserId { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/BanTemplate.cs b/src/EllieBot/Db/Models/punish/BanTemplate.cs new file mode 100644 index 0000000..0c8519f --- /dev/null +++ b/src/EllieBot/Db/Models/punish/BanTemplate.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class BanTemplate : DbEntity +{ + public ulong GuildId { get; set; } + public string Text { get; set; } + public int? PruneDays { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/MutedUserId.cs b/src/EllieBot/Db/Models/punish/MutedUserId.cs new file mode 100644 index 0000000..f067e77 --- /dev/null +++ b/src/EllieBot/Db/Models/punish/MutedUserId.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class MutedUserId : DbEntity +{ + public ulong UserId { get; set; } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is MutedUserId mui ? mui.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/PunishmentAction.cs b/src/EllieBot/Db/Models/punish/PunishmentAction.cs new file mode 100644 index 0000000..5788e65 --- /dev/null +++ b/src/EllieBot/Db/Models/punish/PunishmentAction.cs @@ -0,0 +1,15 @@ +namespace EllieBot.Db.Models; + +public enum PunishmentAction +{ + Mute, + Kick, + Ban, + Softban, + RemoveRoles, + ChatMute, + VoiceMute, + AddRole, + Warn, + TimeOut +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/WarnExpireAction.cs b/src/EllieBot/Db/Models/punish/WarnExpireAction.cs new file mode 100644 index 0000000..0de916e --- /dev/null +++ b/src/EllieBot/Db/Models/punish/WarnExpireAction.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public enum WarnExpireAction +{ + Clear, + Delete +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/Warning.cs b/src/EllieBot/Db/Models/punish/Warning.cs new file mode 100644 index 0000000..454a4cb --- /dev/null +++ b/src/EllieBot/Db/Models/punish/Warning.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class Warning : DbEntity +{ + public ulong GuildId { get; set; } + public ulong UserId { get; set; } + public string Reason { get; set; } + public bool Forgiven { get; set; } + public string ForgivenBy { get; set; } + public string Moderator { get; set; } + public long Weight { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/WarningPunishment.cs b/src/EllieBot/Db/Models/punish/WarningPunishment.cs new file mode 100644 index 0000000..5368938 --- /dev/null +++ b/src/EllieBot/Db/Models/punish/WarningPunishment.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WarningPunishment : DbEntity +{ + public int Count { get; set; } + public PunishmentAction Punishment { get; set; } + public int Time { get; set; } + public ulong? RoleId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/roles/ReactionRole.cs b/src/EllieBot/Db/Models/roles/ReactionRole.cs new file mode 100644 index 0000000..2dedbfe --- /dev/null +++ b/src/EllieBot/Db/Models/roles/ReactionRole.cs @@ -0,0 +1,18 @@ +#nullable disable +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class ReactionRoleV2 : DbEntity +{ + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + + public ulong MessageId { get; set; } + + [MaxLength(100)] + public string Emote { get; set; } + public ulong RoleId { get; set; } + public int Group { get; set; } + public int LevelReq { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/roles/SelfAssignableRole.cs b/src/EllieBot/Db/Models/roles/SelfAssignableRole.cs new file mode 100644 index 0000000..ac147b6 --- /dev/null +++ b/src/EllieBot/Db/Models/roles/SelfAssignableRole.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class SelfAssignedRole : DbEntity +{ + public ulong GuildId { get; set; } + public ulong RoleId { get; set; } + + public int Group { get; set; } + public int LevelRequirement { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/roles/StickyRoles.cs b/src/EllieBot/Db/Models/roles/StickyRoles.cs new file mode 100644 index 0000000..3e01ae9 --- /dev/null +++ b/src/EllieBot/Db/Models/roles/StickyRoles.cs @@ -0,0 +1,14 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public class StickyRole : DbEntity +{ + public ulong GuildId { get; set; } + public string RoleIds { get; set; } + public ulong UserId { get; set; } + + public ulong[] GetRoleIds() + => string.IsNullOrWhiteSpace(RoleIds) + ? [] + : RoleIds.Split(',').Select(ulong.Parse).ToArray(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredRole.cs b/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredRole.cs new file mode 100644 index 0000000..e41c2e7 --- /dev/null +++ b/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredRole.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class SlowmodeIgnoredRole : DbEntity +{ + public ulong RoleId { get; set; } + + // override object.Equals + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + + return ((SlowmodeIgnoredRole)obj).RoleId == RoleId; + } + + // override object.GetHashCode + public override int GetHashCode() + => RoleId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredUser.cs b/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredUser.cs new file mode 100644 index 0000000..7ae0a05 --- /dev/null +++ b/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredUser.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class SlowmodeIgnoredUser : DbEntity +{ + public ulong UserId { get; set; } + + // override object.Equals + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + + return ((SlowmodeIgnoredUser)obj).UserId == UserId; + } + + // override object.GetHashCode + public override int GetHashCode() + => UserId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/support/PatronQuota.cs b/src/EllieBot/Db/Models/support/PatronQuota.cs new file mode 100644 index 0000000..1ebd2fd --- /dev/null +++ b/src/EllieBot/Db/Models/support/PatronQuota.cs @@ -0,0 +1,48 @@ +#nullable disable +namespace EllieBot.Db.Models; + +/// +/// Contains data about usage of Patron-Only commands per user +/// in order to provide support for quota limitations +/// (allow user x who is pledging amount y to use the specified command only +/// x amount of times in the specified time period) +/// +public class PatronQuota +{ + public ulong UserId { get; set; } + public FeatureType FeatureType { get; set; } + public string Feature { get; set; } + public uint HourlyCount { get; set; } + public uint DailyCount { get; set; } + public uint MonthlyCount { get; set; } +} + +public enum FeatureType +{ + Command, + Group, + Module, + Limit +} + +public class PatronUser +{ + public string UniquePlatformUserId { get; set; } + public ulong UserId { get; set; } + public int AmountCents { get; set; } + + public DateTime LastCharge { get; set; } + + // Date Only component + public DateTime ValidThru { get; set; } + + public PatronUser Clone() + => new PatronUser() + { + UniquePlatformUserId = this.UniquePlatformUserId, + UserId = this.UserId, + AmountCents = this.AmountCents, + LastCharge = this.LastCharge, + ValidThru = this.ValidThru + }; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/support/RewardedUser.cs b/src/EllieBot/Db/Models/support/RewardedUser.cs new file mode 100644 index 0000000..bc12bdd --- /dev/null +++ b/src/EllieBot/Db/Models/support/RewardedUser.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class RewardedUser : DbEntity +{ + public ulong UserId { get; set; } + public string PlatformUserId { get; set; } + public long AmountRewardedThisMonth { get; set; } + public DateTime LastReward { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/todo/ArchivedTodoListModel.cs b/src/EllieBot/Db/Models/todo/ArchivedTodoListModel.cs new file mode 100644 index 0000000..b213788 --- /dev/null +++ b/src/EllieBot/Db/Models/todo/ArchivedTodoListModel.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public sealed class ArchivedTodoListModel +{ + public int Id { get; set; } + public ulong UserId { get; set; } + public string Name { get; set; } + public List Items { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/todo/TodoModel.cs b/src/EllieBot/Db/Models/todo/TodoModel.cs new file mode 100644 index 0000000..ba3c8c1 --- /dev/null +++ b/src/EllieBot/Db/Models/todo/TodoModel.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public sealed class TodoModel +{ + public int Id { get; set; } + public ulong UserId { get; set; } + public string Todo { get; set; } + + public DateTime DateAdded { get; set; } + public bool IsDone { get; set; } + public int? ArchiveId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/untimer/UnbanTimer.cs b/src/EllieBot/Db/Models/untimer/UnbanTimer.cs new file mode 100644 index 0000000..2f61402 --- /dev/null +++ b/src/EllieBot/Db/Models/untimer/UnbanTimer.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class UnbanTimer : DbEntity +{ + public ulong UserId { get; set; } + public DateTime UnbanAt { get; set; } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is UnbanTimer ut ? ut.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/untimer/UnmuteTimer.cs b/src/EllieBot/Db/Models/untimer/UnmuteTimer.cs new file mode 100644 index 0000000..18b2903 --- /dev/null +++ b/src/EllieBot/Db/Models/untimer/UnmuteTimer.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class UnmuteTimer : DbEntity +{ + public ulong UserId { get; set; } + public DateTime UnmuteAt { get; set; } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is UnmuteTimer ut ? ut.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/untimer/UnroleTimer.cs b/src/EllieBot/Db/Models/untimer/UnroleTimer.cs new file mode 100644 index 0000000..27193c2 --- /dev/null +++ b/src/EllieBot/Db/Models/untimer/UnroleTimer.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class UnroleTimer : DbEntity +{ + public ulong UserId { get; set; } + public ulong RoleId { get; set; } + public DateTime UnbanAt { get; set; } + + public override int GetHashCode() + => UserId.GetHashCode() ^ RoleId.GetHashCode(); + + public override bool Equals(object obj) + => obj is UnroleTimer ut ? ut.UserId == UserId && ut.RoleId == RoleId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/xp/UserXpStats.cs b/src/EllieBot/Db/Models/xp/UserXpStats.cs new file mode 100644 index 0000000..d603360 --- /dev/null +++ b/src/EllieBot/Db/Models/xp/UserXpStats.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class UserXpStats : DbEntity +{ + public ulong UserId { get; set; } + public ulong GuildId { get; set; } + public long Xp { get; set; } + public long AwardedXp { get; set; } + public XpNotificationLocation NotifyOnLevelUp { get; set; } +} + +public enum XpNotificationLocation { None, Dm, Channel } \ No newline at end of file diff --git a/src/EllieBot/Db/Models/xp/XpSettings.cs b/src/EllieBot/Db/Models/xp/XpSettings.cs new file mode 100644 index 0000000..50fd5be --- /dev/null +++ b/src/EllieBot/Db/Models/xp/XpSettings.cs @@ -0,0 +1,64 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class XpSettings : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + public HashSet RoleRewards { get; set; } = new(); + public HashSet CurrencyRewards { get; set; } = new(); + public HashSet ExclusionList { get; set; } = new(); + public bool ServerExcluded { get; set; } +} + +public enum ExcludedItemType { Channel, Role } + +public class XpRoleReward : DbEntity +{ + public int XpSettingsId { get; set; } + public XpSettings XpSettings { get; set; } + + public int Level { get; set; } + public ulong RoleId { get; set; } + + /// + /// Whether the role should be removed (true) or added (false) + /// + public bool Remove { get; set; } + + public override int GetHashCode() + => Level.GetHashCode() ^ XpSettingsId.GetHashCode(); + + public override bool Equals(object obj) + => obj is XpRoleReward xrr && xrr.Level == Level && xrr.XpSettingsId == XpSettingsId; +} + +public class XpCurrencyReward : DbEntity +{ + public int XpSettingsId { get; set; } + public XpSettings XpSettings { get; set; } + + public int Level { get; set; } + public int Amount { get; set; } + + public override int GetHashCode() + => Level.GetHashCode() ^ XpSettingsId.GetHashCode(); + + public override bool Equals(object obj) + => obj is XpCurrencyReward xrr && xrr.Level == Level && xrr.XpSettingsId == XpSettingsId; +} + +public class ExcludedItem : DbEntity +{ + public XpSettings XpSettings { get; set; } + + public ulong ItemId { get; set; } + public ExcludedItemType ItemType { get; set; } + + public override int GetHashCode() + => ItemId.GetHashCode() ^ ItemType.GetHashCode(); + + public override bool Equals(object obj) + => obj is ExcludedItem ei && ei.ItemId == ItemId && ei.ItemType == ItemType; +} \ No newline at end of file diff --git a/src/EllieBot/Db/MysqlContext.cs b/src/EllieBot/Db/MysqlContext.cs new file mode 100644 index 0000000..7563640 --- /dev/null +++ b/src/EllieBot/Db/MysqlContext.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public sealed class MysqlContext : EllieContext +{ + private readonly string _connStr; + private readonly string _version; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public MysqlContext(string connStr = "Server=localhost", string version = "8.0") + { + _connStr = connStr; + _version = version; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + optionsBuilder + .UseLowerCaseNamingConvention() + .UseMySql(_connStr, ServerVersion.Parse(_version)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // mysql is case insensitive by default + // we can set binary collation to change that + modelBuilder.Entity() + .Property(x => x.Name) + .UseCollation("utf8mb4_bin"); + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/PostgreSqlContext.cs b/src/EllieBot/Db/PostgreSqlContext.cs new file mode 100644 index 0000000..2305d19 --- /dev/null +++ b/src/EllieBot/Db/PostgreSqlContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Db; + +public sealed class PostgreSqlContext : EllieContext +{ + private readonly string _connStr; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public PostgreSqlContext(string connStr = "Host=localhost") + { + _connStr = connStr; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + base.OnConfiguring(optionsBuilder); + optionsBuilder + .UseLowerCaseNamingConvention() + .UseNpgsql(_connStr); + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/SqliteContext.cs b/src/EllieBot/Db/SqliteContext.cs new file mode 100644 index 0000000..516d445 --- /dev/null +++ b/src/EllieBot/Db/SqliteContext.cs @@ -0,0 +1,26 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Db; + +public sealed class SqliteContext : EllieContext +{ + private readonly string _connectionString; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public SqliteContext(string connectionString = "Data Source=data/EllieBot.db", int commandTimeout = 60) + { + _connectionString = connectionString; + Database.SetCommandTimeout(commandTimeout); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + var builder = new SqliteConnectionStringBuilder(_connectionString); + builder.DataSource = Path.Combine(AppContext.BaseDirectory, builder.DataSource); + optionsBuilder.UseSqlite(builder.ToString()); + } +} \ No newline at end of file diff --git a/src/EllieBot/EllieBot.csproj b/src/EllieBot/EllieBot.csproj index 74abf5c..2c605d4 100644 --- a/src/EllieBot/EllieBot.csproj +++ b/src/EllieBot/EllieBot.csproj @@ -1,10 +1,143 @@ - - + - Exe - net6.0 - enable - enable - + net8.0 + enable + true + en + 5.0.8 + + $(MSBuildProjectDirectory) + exe + ellie_icon.ico + + + + + CS1066;CS8981 + + + true + embedded + + + + + + all + True + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Protos\coordinator.proto + + + true + PreserveNewest + + + true + Always + + + true + Always + + + + + + false + GLOBAL_ELLIE + $(NoWarn);CS1573;CS1591 + true + portable + false + diff --git a/src/EllieBot/GlobalUsings.cs b/src/EllieBot/GlobalUsings.cs new file mode 100644 index 0000000..dbac5fd --- /dev/null +++ b/src/EllieBot/GlobalUsings.cs @@ -0,0 +1,30 @@ +// global using System.Collections.Concurrent +global using NonBlocking; + +// packages +global using Serilog; + +// elliebot +global using EllieBot; +global using EllieBot.Db; +global using EllieBot.Services; +global using Ellie.Common; // new project +global using EllieBot.Common; // old + elliebot specific things +global using EllieBot.Common.Attributes; +global using EllieBot.Extensions; + +// discord +global using Discord; +global using Discord.Commands; +global using Discord.Net; +global using Discord.WebSocket; + +// aliases +global using GuildPerm = Discord.GuildPermission; +global using ChannelPerm = Discord.ChannelPermission; +global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute; +global using LeftoverAttribute = Discord.Commands.RemainderAttribute; +global using TypeReaderResult = EllieBot.Common.TypeReaders.TypeReaderResult; + +// non-essential +global using JetBrains.Annotations; \ No newline at end of file diff --git a/src/EllieBot/Migrations/MigrationQueries.cs b/src/EllieBot/Migrations/MigrationQueries.cs new file mode 100644 index 0000000..8265ebb --- /dev/null +++ b/src/EllieBot/Migrations/MigrationQueries.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using EllieBot.Db.Models; + +namespace EllieBot.Migrations; + +public static class MigrationQueries +{ + public static void MigrateRero(MigrationBuilder migrationBuilder) + { + if (migrationBuilder.IsMySql()) + { + migrationBuilder.Sql( + @"INSERT IGNORE into reactionroles(guildid, channelid, messageid, emote, roleid, `group`, levelreq, dateadded) +select guildid, channelid, messageid, emotename, roleid, exclusive, 0, reactionrolemessage.dateadded +from reactionrole +left join reactionrolemessage on reactionrolemessage.id = reactionrole.reactionrolemessageid +left join guildconfigs on reactionrolemessage.guildconfigid = guildconfigs.id;"); + } + else if (migrationBuilder.IsSqlite()) + { + migrationBuilder.Sql( + @"insert or ignore into reactionroles(guildid, channelid, messageid, emote, roleid, 'group', levelreq, dateadded) +select guildid, channelid, messageid, emotename, roleid, exclusive, 0, reactionrolemessage.dateadded +from reactionrole +left join reactionrolemessage on reactionrolemessage.id = reactionrole.reactionrolemessageid +left join guildconfigs on reactionrolemessage.guildconfigid = guildconfigs.id;"); + } + else if (migrationBuilder.IsNpgsql()) + { + migrationBuilder.Sql(@"insert into reactionroles(guildid, channelid, messageid, emote, roleid, ""group"", levelreq, dateadded) + select guildid, channelid, messageid, emotename, roleid, exclusive::int, 0, reactionrolemessage.dateadded + from reactionrole + left join reactionrolemessage on reactionrolemessage.id = reactionrole.reactionrolemessageid + left join guildconfigs on reactionrolemessage.guildconfigid = guildconfigs.id + ON CONFLICT DO NOTHING;"); + } + else + { + throw new NotSupportedException("This database provider doesn't have an implementation for MigrateRero"); + } + } + + public static void GuildConfigCleanup(MigrationBuilder builder) + { + builder.Sql($""" + DELETE FROM "StreamRoleBlacklistedUser" WHERE "StreamRoleSettingsId" is NULL; + """); + + builder.Sql($""" + DELETE FROM "DelMsgOnCmdChannel" WHERE "GuildConfigId" is NULL; + """); + } +} \ No newline at end of file diff --git a/src/EllieBot/Migrations/Mysql/20220409170652_mysql-init.Designer.cs b/src/EllieBot/Migrations/Mysql/20220409170652_mysql-init.Designer.cs new file mode 100644 index 0000000..458c74a --- /dev/null +++ b/src/EllieBot/Migrations/Mysql/20220409170652_mysql-init.Designer.cs @@ -0,0 +1,3425 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using EllieBot.Services.Database; + +#nullable disable + +namespace EllieBot.Migrations.Mysql +{ + [DbContext(typeof(MysqlContext))] + [Migration("20220409170652_mysql-init")] + partial class mysqlinit + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.Property("ClubId") + .HasColumnType("int") + .HasColumnName("clubid"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("userid"); + + b.HasKey("ClubId", "UserId") + .HasName("pk_clubapplicants"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_clubapplicants_userid"); + + b.ToTable("clubapplicants", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.Property("ClubId") + .HasColumnType("int") + .HasColumnName("clubid"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("userid"); + + b.HasKey("ClubId", "UserId") + .HasName("pk_clubbans"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_clubbans_userid"); + + b.ToTable("clubbans", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Description") + .HasColumnType("longtext") + .HasColumnName("description"); + + b.Property("ImageUrl") + .HasColumnType("longtext") + .HasColumnName("imageurl"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("name") + .UseCollation("utf8mb4_bin"); + + b.Property("OwnerId") + .HasColumnType("int") + .HasColumnName("ownerid"); + + b.Property("Xp") + .HasColumnType("int") + .HasColumnName("xp"); + + b.HasKey("Id") + .HasName("pk_clubs"); + + b.HasAlternateKey("Name") + .HasName("ak_clubs_name"); + + b.HasIndex("OwnerId") + .IsUnique() + .HasDatabaseName("ix_clubs_ownerid"); + + b.ToTable("clubs", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AvatarId") + .HasColumnType("longtext") + .HasColumnName("avatarid"); + + b.Property("ClubId") + .HasColumnType("int") + .HasColumnName("clubid"); + + b.Property("CurrencyAmount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("currencyamount"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Discriminator") + .HasColumnType("longtext") + .HasColumnName("discriminator"); + + b.Property("IsClubAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("isclubadmin"); + + b.Property("LastLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("lastlevelup") + .HasDefaultValueSql("(UTC_TIMESTAMP)"); + + b.Property("LastXpGain") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("lastxpgain") + .HasDefaultValueSql("(UTC_TIMESTAMP - INTERVAL 1 year)"); + + b.Property("NotifyOnLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("notifyonlevelup"); + + b.Property("TotalXp") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("totalxp"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("longtext") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_discorduser"); + + b.HasAlternateKey("UserId") + .HasName("ak_discorduser_userid"); + + b.HasIndex("ClubId") + .HasDatabaseName("ix_discorduser_clubid"); + + b.HasIndex("CurrencyAmount") + .HasDatabaseName("ix_discorduser_currencyamount"); + + b.HasIndex("TotalXp") + .HasDatabaseName("ix_discorduser_totalxp"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_discorduser_userid"); + + b.ToTable("discorduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Message") + .HasColumnType("longtext") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.Property("Username") + .HasColumnType("longtext") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_followedstream"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_followedstream_guildconfigid"); + + b.ToTable("followedstream", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiAltSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("int") + .HasColumnName("action"); + + b.Property("ActionDurationMinutes") + .HasColumnType("int") + .HasColumnName("actiondurationminutes"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("MinAge") + .HasColumnType("time(6)") + .HasColumnName("minage"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_antialtsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antialtsetting_guildconfigid"); + + b.ToTable("antialtsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiRaidSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("int") + .HasColumnName("action"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("PunishDuration") + .HasColumnType("int") + .HasColumnName("punishduration"); + + b.Property("Seconds") + .HasColumnType("int") + .HasColumnName("seconds"); + + b.Property("UserThreshold") + .HasColumnType("int") + .HasColumnName("userthreshold"); + + b.HasKey("Id") + .HasName("pk_antiraidsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antiraidsetting_guildconfigid"); + + b.ToTable("antiraidsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamIgnore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AntiSpamSettingId") + .HasColumnType("int") + .HasColumnName("antispamsettingid"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.HasKey("Id") + .HasName("pk_antispamignore"); + + b.HasIndex("AntiSpamSettingId") + .HasDatabaseName("ix_antispamignore_antispamsettingid"); + + b.ToTable("antispamignore", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("int") + .HasColumnName("action"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("MessageThreshold") + .HasColumnType("int") + .HasColumnName("messagethreshold"); + + b.Property("MuteTime") + .HasColumnType("int") + .HasColumnName("mutetime"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_antispamsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antispamsetting_guildconfigid"); + + b.ToTable("antispamsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("ChannelName") + .HasColumnType("longtext") + .HasColumnName("channelname"); + + b.Property("CommandText") + .HasColumnType("longtext") + .HasColumnName("commandtext"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("GuildName") + .HasColumnType("longtext") + .HasColumnName("guildname"); + + b.Property("Interval") + .HasColumnType("int") + .HasColumnName("interval"); + + b.Property("VoiceChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("voicechannelid"); + + b.Property("VoiceChannelName") + .HasColumnType("longtext") + .HasColumnName("voicechannelname"); + + b.HasKey("Id") + .HasName("pk_autocommands"); + + b.ToTable("autocommands", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoTranslateChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AutoDelete") + .HasColumnType("tinyint(1)") + .HasColumnName("autodelete"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.HasKey("Id") + .HasName("pk_autotranslatechannels"); + + b.HasIndex("ChannelId") + .IsUnique() + .HasDatabaseName("ix_autotranslatechannels_channelid"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_autotranslatechannels_guildid"); + + b.ToTable("autotranslatechannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoTranslateUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("int") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Source") + .HasColumnType("longtext") + .HasColumnName("source"); + + b.Property("Target") + .HasColumnType("longtext") + .HasColumnName("target"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_autotranslateusers"); + + b.HasAlternateKey("ChannelId", "UserId") + .HasName("ak_autotranslateusers_channelid_userid"); + + b.ToTable("autotranslateusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.BanTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Text") + .HasColumnType("longtext") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_bantemplates"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_bantemplates_guildid"); + + b.ToTable("bantemplates", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.BlacklistEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ItemId") + .HasColumnType("bigint unsigned") + .HasColumnName("itemid"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_blacklist"); + + b.ToTable("blacklist", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CommandAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Mapping") + .HasColumnType("longtext") + .HasColumnName("mapping"); + + b.Property("Trigger") + .HasColumnType("longtext") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("pk_commandalias"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_commandalias_guildconfigid"); + + b.ToTable("commandalias", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CommandCooldown", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("CommandName") + .HasColumnType("longtext") + .HasColumnName("commandname"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Seconds") + .HasColumnType("int") + .HasColumnName("seconds"); + + b.HasKey("Id") + .HasName("pk_commandcooldown"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_commandcooldown_guildconfigid"); + + b.ToTable("commandcooldown", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CurrencyTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("extra"); + + b.Property("Note") + .HasColumnType("longtext") + .HasColumnName("note"); + + b.Property("OtherId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint unsigned") + .HasColumnName("otherid") + .HasDefaultValueSql("NULL"); + + b.Property("Type") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_currencytransactions"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_currencytransactions_userid"); + + b.ToTable("currencytransactions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.DelMsgOnCmdChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("State") + .HasColumnType("tinyint(1)") + .HasColumnName("state"); + + b.HasKey("Id") + .HasName("pk_delmsgoncmdchannel"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_delmsgoncmdchannel_guildconfigid"); + + b.ToTable("delmsgoncmdchannel", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.DiscordPermOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Command") + .HasColumnType("varchar(255)") + .HasColumnName("command"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Perm") + .HasColumnType("bigint unsigned") + .HasColumnName("perm"); + + b.HasKey("Id") + .HasName("pk_discordpermoverrides"); + + b.HasIndex("GuildId", "Command") + .IsUnique() + .HasDatabaseName("ix_discordpermoverrides_guildid_command"); + + b.ToTable("discordpermoverrides", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ExcludedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ItemId") + .HasColumnType("bigint unsigned") + .HasColumnName("itemid"); + + b.Property("ItemType") + .HasColumnType("int") + .HasColumnName("itemtype"); + + b.Property("XpSettingsId") + .HasColumnType("int") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_excludeditem"); + + b.HasIndex("XpSettingsId") + .HasDatabaseName("ix_excludeditem_xpsettingsid"); + + b.ToTable("excludeditem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FeedSub", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Url") + .IsRequired() + .HasColumnType("varchar(255)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_feedsub"); + + b.HasAlternateKey("GuildConfigId", "Url") + .HasName("ak_feedsub_guildconfigid_url"); + + b.ToTable("feedsub", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterchannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterchannelid_guildconfigid"); + + b.ToTable("filterchannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilteredWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Word") + .HasColumnType("longtext") + .HasColumnName("word"); + + b.HasKey("Id") + .HasName("pk_filteredword"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filteredword_guildconfigid"); + + b.ToTable("filteredword", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterLinksChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterlinkschannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterlinkschannelid_guildconfigid"); + + b.ToTable("filterlinkschannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterWordsChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterwordschannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterwordschannelid_guildconfigid"); + + b.ToTable("filterwordschannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GCChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_gcchannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_gcchannelid_guildconfigid"); + + b.ToTable("gcchannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GroupName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("Number") + .HasColumnType("int") + .HasColumnName("number"); + + b.HasKey("Id") + .HasName("pk_groupname"); + + b.HasIndex("GuildConfigId", "Number") + .IsUnique() + .HasDatabaseName("ix_groupname_guildconfigid_number"); + + b.ToTable("groupname", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GuildConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AutoAssignRoleIds") + .HasColumnType("longtext") + .HasColumnName("autoassignroleids"); + + b.Property("AutoDeleteByeMessagesTimer") + .HasColumnType("int") + .HasColumnName("autodeletebyemessagestimer"); + + b.Property("AutoDeleteGreetMessagesTimer") + .HasColumnType("int") + .HasColumnName("autodeletegreetmessagestimer"); + + b.Property("AutoDeleteSelfAssignedRoleMessages") + .HasColumnType("tinyint(1)") + .HasColumnName("autodeleteselfassignedrolemessages"); + + b.Property("BoostMessage") + .HasColumnType("longtext") + .HasColumnName("boostmessage"); + + b.Property("BoostMessageChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("boostmessagechannelid"); + + b.Property("BoostMessageDeleteAfter") + .HasColumnType("int") + .HasColumnName("boostmessagedeleteafter"); + + b.Property("ByeMessageChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("byemessagechannelid"); + + b.Property("ChannelByeMessageText") + .HasColumnType("longtext") + .HasColumnName("channelbyemessagetext"); + + b.Property("ChannelGreetMessageText") + .HasColumnType("longtext") + .HasColumnName("channelgreetmessagetext"); + + b.Property("CleverbotEnabled") + .HasColumnType("tinyint(1)") + .HasColumnName("cleverbotenabled"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("DeleteMessageOnCommand") + .HasColumnType("tinyint(1)") + .HasColumnName("deletemessageoncommand"); + + b.Property("DmGreetMessageText") + .HasColumnType("longtext") + .HasColumnName("dmgreetmessagetext"); + + b.Property("ExclusiveSelfAssignedRoles") + .HasColumnType("tinyint(1)") + .HasColumnName("exclusiveselfassignedroles"); + + b.Property("FilterInvites") + .HasColumnType("tinyint(1)") + .HasColumnName("filterinvites"); + + b.Property("FilterLinks") + .HasColumnType("tinyint(1)") + .HasColumnName("filterlinks"); + + b.Property("FilterWords") + .HasColumnType("tinyint(1)") + .HasColumnName("filterwords"); + + b.Property("GameVoiceChannel") + .HasColumnType("bigint unsigned") + .HasColumnName("gamevoicechannel"); + + b.Property("GreetMessageChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("greetmessagechannelid"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Locale") + .HasColumnType("longtext") + .HasColumnName("locale"); + + b.Property("MuteRoleName") + .HasColumnType("longtext") + .HasColumnName("muterolename"); + + b.Property("NotifyStreamOffline") + .HasColumnType("tinyint(1)") + .HasColumnName("notifystreamoffline"); + + b.Property("PermissionRole") + .HasColumnType("longtext") + .HasColumnName("permissionrole"); + + b.Property("Prefix") + .HasColumnType("longtext") + .HasColumnName("prefix"); + + b.Property("SendBoostMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("sendboostmessage"); + + b.Property("SendChannelByeMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("sendchannelbyemessage"); + + b.Property("SendChannelGreetMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("sendchannelgreetmessage"); + + b.Property("SendDmGreetMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("senddmgreetmessage"); + + b.Property("TimeZoneId") + .HasColumnType("longtext") + .HasColumnName("timezoneid"); + + b.Property("VerboseErrors") + .HasColumnType("tinyint(1)") + .HasColumnName("verboseerrors"); + + b.Property("VerbosePermissions") + .HasColumnType("tinyint(1)") + .HasColumnName("verbosepermissions"); + + b.Property("WarnExpireAction") + .HasColumnType("int") + .HasColumnName("warnexpireaction"); + + b.Property("WarnExpireHours") + .HasColumnType("int") + .HasColumnName("warnexpirehours"); + + b.Property("WarningsInitialized") + .HasColumnType("tinyint(1)") + .HasColumnName("warningsinitialized"); + + b.HasKey("Id") + .HasName("pk_guildconfigs"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_guildconfigs_guildid"); + + b.HasIndex("WarnExpireHours") + .HasDatabaseName("ix_guildconfigs_warnexpirehours"); + + b.ToTable("guildconfigs", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.IgnoredLogItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ItemType") + .HasColumnType("int") + .HasColumnName("itemtype"); + + b.Property("LogItemId") + .HasColumnType("bigint unsigned") + .HasColumnName("logitemid"); + + b.Property("LogSettingId") + .HasColumnType("int") + .HasColumnName("logsettingid"); + + b.HasKey("Id") + .HasName("pk_ignoredlogchannels"); + + b.HasIndex("LogSettingId", "LogItemId", "ItemType") + .IsUnique() + .HasDatabaseName("ix_ignoredlogchannels_logsettingid_logitemid_itemtype"); + + b.ToTable("ignoredlogchannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.IgnoredVoicePresenceChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("LogSettingId") + .HasColumnType("int") + .HasColumnName("logsettingid"); + + b.HasKey("Id") + .HasName("pk_ignoredvoicepresencechannels"); + + b.HasIndex("LogSettingId") + .HasDatabaseName("ix_ignoredvoicepresencechannels_logsettingid"); + + b.ToTable("ignoredvoicepresencechannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ImageOnlyChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.HasKey("Id") + .HasName("pk_imageonlychannels"); + + b.HasIndex("ChannelId") + .IsUnique() + .HasDatabaseName("ix_imageonlychannels_channelid"); + + b.ToTable("imageonlychannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.LogSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelCreatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelcreatedid"); + + b.Property("ChannelDestroyedId") + .HasColumnType("bigint unsigned") + .HasColumnName("channeldestroyedid"); + + b.Property("ChannelUpdatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelupdatedid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("LogOtherId") + .HasColumnType("bigint unsigned") + .HasColumnName("logotherid"); + + b.Property("LogUserPresenceId") + .HasColumnType("bigint unsigned") + .HasColumnName("loguserpresenceid"); + + b.Property("LogVoicePresenceId") + .HasColumnType("bigint unsigned") + .HasColumnName("logvoicepresenceid"); + + b.Property("LogVoicePresenceTTSId") + .HasColumnType("bigint unsigned") + .HasColumnName("logvoicepresencettsid"); + + b.Property("MessageDeletedId") + .HasColumnType("bigint unsigned") + .HasColumnName("messagedeletedid"); + + b.Property("MessageUpdatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("messageupdatedid"); + + b.Property("UserBannedId") + .HasColumnType("bigint unsigned") + .HasColumnName("userbannedid"); + + b.Property("UserJoinedId") + .HasColumnType("bigint unsigned") + .HasColumnName("userjoinedid"); + + b.Property("UserLeftId") + .HasColumnType("bigint unsigned") + .HasColumnName("userleftid"); + + b.Property("UserMutedId") + .HasColumnType("bigint unsigned") + .HasColumnName("usermutedid"); + + b.Property("UserUnbannedId") + .HasColumnType("bigint unsigned") + .HasColumnName("userunbannedid"); + + b.Property("UserUpdatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("userupdatedid"); + + b.HasKey("Id") + .HasName("pk_logsettings"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_logsettings_guildid"); + + b.ToTable("logsettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MusicPlayerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AutoDisconnect") + .HasColumnType("tinyint(1)") + .HasColumnName("autodisconnect"); + + b.Property("AutoPlay") + .HasColumnType("tinyint(1)") + .HasColumnName("autoplay"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("MusicChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("musicchannelid"); + + b.Property("PlayerRepeat") + .HasColumnType("int") + .HasColumnName("playerrepeat"); + + b.Property("QualityPreset") + .HasColumnType("int") + .HasColumnName("qualitypreset"); + + b.Property("Volume") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(100) + .HasColumnName("volume"); + + b.HasKey("Id") + .HasName("pk_musicplayersettings"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_musicplayersettings_guildid"); + + b.ToTable("musicplayersettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MusicPlaylist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Author") + .HasColumnType("longtext") + .HasColumnName("author"); + + b.Property("AuthorId") + .HasColumnType("bigint unsigned") + .HasColumnName("authorid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_musicplaylists"); + + b.ToTable("musicplaylists", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MutedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_muteduserid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_muteduserid_guildconfigid"); + + b.ToTable("muteduserid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.EllieExpression", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AllowTarget") + .HasColumnType("tinyint(1)") + .HasColumnName("allowtarget"); + + b.Property("AutoDeleteTrigger") + .HasColumnType("tinyint(1)") + .HasColumnName("autodeletetrigger"); + + b.Property("ContainsAnywhere") + .HasColumnType("tinyint(1)") + .HasColumnName("containsanywhere"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("DmResponse") + .HasColumnType("tinyint(1)") + .HasColumnName("dmresponse"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Reactions") + .HasColumnType("longtext") + .HasColumnName("reactions"); + + b.Property("Response") + .HasColumnType("longtext") + .HasColumnName("response"); + + b.Property("Trigger") + .HasColumnType("longtext") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("pk_expressions"); + + b.ToTable("expressions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.NsfwBlacklistedTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Tag") + .HasColumnType("longtext") + .HasColumnName("tag"); + + b.HasKey("Id") + .HasName("pk_nsfwblacklistedtags"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_nsfwblacklistedtags_guildid"); + + b.ToTable("nsfwblacklistedtags", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Permissionv2", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("int") + .HasColumnName("index"); + + b.Property("IsCustomCommand") + .HasColumnType("tinyint(1)") + .HasColumnName("iscustomcommand"); + + b.Property("PrimaryTarget") + .HasColumnType("int") + .HasColumnName("primarytarget"); + + b.Property("PrimaryTargetId") + .HasColumnType("bigint unsigned") + .HasColumnName("primarytargetid"); + + b.Property("SecondaryTarget") + .HasColumnType("int") + .HasColumnName("secondarytarget"); + + b.Property("SecondaryTargetName") + .HasColumnType("longtext") + .HasColumnName("secondarytargetname"); + + b.Property("State") + .HasColumnType("tinyint(1)") + .HasColumnName("state"); + + b.HasKey("Id") + .HasName("pk_permissions"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_permissions_guildconfigid"); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PlantedCurrency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("MessageId") + .HasColumnType("bigint unsigned") + .HasColumnName("messageid"); + + b.Property("Password") + .HasColumnType("longtext") + .HasColumnName("password"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_plantedcurrency"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_plantedcurrency_channelid"); + + b.HasIndex("MessageId") + .IsUnique() + .HasDatabaseName("ix_plantedcurrency_messageid"); + + b.ToTable("plantedcurrency", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PlaylistSong", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("MusicPlaylistId") + .HasColumnType("int") + .HasColumnName("musicplaylistid"); + + b.Property("Provider") + .HasColumnType("longtext") + .HasColumnName("provider"); + + b.Property("ProviderType") + .HasColumnType("int") + .HasColumnName("providertype"); + + b.Property("Query") + .HasColumnType("longtext") + .HasColumnName("query"); + + b.Property("Title") + .HasColumnType("longtext") + .HasColumnName("title"); + + b.Property("Uri") + .HasColumnType("longtext") + .HasColumnName("uri"); + + b.HasKey("Id") + .HasName("pk_playlistsong"); + + b.HasIndex("MusicPlaylistId") + .HasDatabaseName("ix_playlistsong_musicplaylistid"); + + b.ToTable("playlistsong", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Poll", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Question") + .HasColumnType("longtext") + .HasColumnName("question"); + + b.HasKey("Id") + .HasName("pk_poll"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_poll_guildid"); + + b.ToTable("poll", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PollAnswer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Index") + .HasColumnType("int") + .HasColumnName("index"); + + b.Property("PollId") + .HasColumnType("int") + .HasColumnName("pollid"); + + b.Property("Text") + .HasColumnType("longtext") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_pollanswer"); + + b.HasIndex("PollId") + .HasDatabaseName("ix_pollanswer_pollid"); + + b.ToTable("pollanswer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PollVote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("PollId") + .HasColumnType("int") + .HasColumnName("pollid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("VoteIndex") + .HasColumnType("int") + .HasColumnName("voteindex"); + + b.HasKey("Id") + .HasName("pk_pollvote"); + + b.HasIndex("PollId") + .HasDatabaseName("ix_pollvote_pollid"); + + b.ToTable("pollvote", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Quote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("bigint unsigned") + .HasColumnName("authorid"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("authorname"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Keyword") + .IsRequired() + .HasColumnType("varchar(255)") + .HasColumnName("keyword"); + + b.Property("Text") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_quotes"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_quotes_guildid"); + + b.HasIndex("Keyword") + .HasDatabaseName("ix_quotes_keyword"); + + b.ToTable("quotes", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("EmoteName") + .HasColumnType("longtext") + .HasColumnName("emotename"); + + b.Property("ReactionRoleMessageId") + .HasColumnType("int") + .HasColumnName("reactionrolemessageid"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_reactionrole"); + + b.HasIndex("ReactionRoleMessageId") + .HasDatabaseName("ix_reactionrole_reactionrolemessageid"); + + b.ToTable("reactionrole", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRoleMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Exclusive") + .HasColumnType("tinyint(1)") + .HasColumnName("exclusive"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("int") + .HasColumnName("index"); + + b.Property("MessageId") + .HasColumnType("bigint unsigned") + .HasColumnName("messageid"); + + b.HasKey("Id") + .HasName("pk_reactionrolemessage"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_reactionrolemessage_guildconfigid"); + + b.ToTable("reactionrolemessage", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Reminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("IsPrivate") + .HasColumnType("tinyint(1)") + .HasColumnName("isprivate"); + + b.Property("Message") + .HasColumnType("longtext") + .HasColumnName("message"); + + b.Property("ServerId") + .HasColumnType("bigint unsigned") + .HasColumnName("serverid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("When") + .HasColumnType("datetime(6)") + .HasColumnName("when"); + + b.HasKey("Id") + .HasName("pk_reminders"); + + b.HasIndex("When") + .HasDatabaseName("ix_reminders_when"); + + b.ToTable("reminders", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Repeater", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Interval") + .HasColumnType("time(6)") + .HasColumnName("interval"); + + b.Property("LastMessageId") + .HasColumnType("bigint unsigned") + .HasColumnName("lastmessageid"); + + b.Property("Message") + .HasColumnType("longtext") + .HasColumnName("message"); + + b.Property("NoRedundant") + .HasColumnType("tinyint(1)") + .HasColumnName("noredundant"); + + b.Property("StartTimeOfDay") + .HasColumnType("time(6)") + .HasColumnName("starttimeofday"); + + b.HasKey("Id") + .HasName("pk_repeaters"); + + b.ToTable("repeaters", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.RewardedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AmountRewardedThisMonth") + .HasColumnType("int") + .HasColumnName("amountrewardedthismonth"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("LastReward") + .HasColumnType("datetime(6)") + .HasColumnName("lastreward"); + + b.Property("PatreonUserId") + .HasColumnType("varchar(255)") + .HasColumnName("patreonuserid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_rewardedusers"); + + b.HasIndex("PatreonUserId") + .IsUnique() + .HasDatabaseName("ix_rewardedusers_patreonuserid"); + + b.ToTable("rewardedusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.RotatingPlayingStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Status") + .HasColumnType("longtext") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_rotatingstatus"); + + b.ToTable("rotatingstatus", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SelfAssignedRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Group") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("group"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("LevelRequirement") + .HasColumnType("int") + .HasColumnName("levelrequirement"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_selfassignableroles"); + + b.HasIndex("GuildId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_selfassignableroles_guildid_roleid"); + + b.ToTable("selfassignableroles", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("bigint unsigned") + .HasColumnName("authorid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("int") + .HasColumnName("index"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("Price") + .HasColumnType("int") + .HasColumnName("price"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("RoleName") + .HasColumnType("longtext") + .HasColumnName("rolename"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_shopentry"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_shopentry_guildconfigid"); + + b.ToTable("shopentry", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ShopEntryId") + .HasColumnType("int") + .HasColumnName("shopentryid"); + + b.Property("Text") + .HasColumnType("longtext") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_shopentryitem"); + + b.HasIndex("ShopEntryId") + .HasDatabaseName("ix_shopentryitem_shopentryid"); + + b.ToTable("shopentryitem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SlowmodeIgnoredRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_slowmodeignoredrole"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_slowmodeignoredrole_guildconfigid"); + + b.ToTable("slowmodeignoredrole", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SlowmodeIgnoredUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_slowmodeignoreduser"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_slowmodeignoreduser_guildconfigid"); + + b.ToTable("slowmodeignoreduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleBlacklistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("int") + .HasColumnName("streamrolesettingsid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("longtext") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_streamroleblacklisteduser"); + + b.HasIndex("StreamRoleSettingsId") + .HasDatabaseName("ix_streamroleblacklisteduser_streamrolesettingsid"); + + b.ToTable("streamroleblacklisteduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AddRoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("addroleid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)") + .HasColumnName("enabled"); + + b.Property("FromRoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("fromroleid"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Keyword") + .HasColumnType("longtext") + .HasColumnName("keyword"); + + b.HasKey("Id") + .HasName("pk_streamrolesettings"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_streamrolesettings_guildconfigid"); + + b.ToTable("streamrolesettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleWhitelistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("int") + .HasColumnName("streamrolesettingsid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("longtext") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_streamrolewhitelisteduser"); + + b.HasIndex("StreamRoleSettingsId") + .HasDatabaseName("ix_streamrolewhitelisteduser_streamrolesettingsid"); + + b.ToTable("streamrolewhitelisteduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnbanTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("UnbanAt") + .HasColumnType("datetime(6)") + .HasColumnName("unbanat"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unbantimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unbantimer_guildconfigid"); + + b.ToTable("unbantimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnmuteTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("UnmuteAt") + .HasColumnType("datetime(6)") + .HasColumnName("unmuteat"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unmutetimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unmutetimer_guildconfigid"); + + b.ToTable("unmutetimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnroleTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("UnbanAt") + .HasColumnType("datetime(6)") + .HasColumnName("unbanat"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unroletimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unroletimer_guildconfigid"); + + b.ToTable("unroletimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UserXpStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AwardedXp") + .HasColumnType("int") + .HasColumnName("awardedxp"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("LastLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("lastlevelup") + .HasDefaultValueSql("(UTC_TIMESTAMP)"); + + b.Property("NotifyOnLevelUp") + .HasColumnType("int") + .HasColumnName("notifyonlevelup"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Xp") + .HasColumnType("int") + .HasColumnName("xp"); + + b.HasKey("Id") + .HasName("pk_userxpstats"); + + b.HasIndex("AwardedXp") + .HasDatabaseName("ix_userxpstats_awardedxp"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_userxpstats_guildid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_userxpstats_userid"); + + b.HasIndex("Xp") + .HasDatabaseName("ix_userxpstats_xp"); + + b.HasIndex("UserId", "GuildId") + .IsUnique() + .HasDatabaseName("ix_userxpstats_userid_guildid"); + + b.ToTable("userxpstats", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.VcRoleInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("VoiceChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("voicechannelid"); + + b.HasKey("Id") + .HasName("pk_vcroleinfo"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_vcroleinfo_guildconfigid"); + + b.ToTable("vcroleinfo", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AffinityId") + .HasColumnType("int") + .HasColumnName("affinityid"); + + b.Property("ClaimerId") + .HasColumnType("int") + .HasColumnName("claimerid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Price") + .HasColumnType("bigint") + .HasColumnName("price"); + + b.Property("WaifuId") + .HasColumnType("int") + .HasColumnName("waifuid"); + + b.HasKey("Id") + .HasName("pk_waifuinfo"); + + b.HasIndex("AffinityId") + .HasDatabaseName("ix_waifuinfo_affinityid"); + + b.HasIndex("ClaimerId") + .HasDatabaseName("ix_waifuinfo_claimerid"); + + b.HasIndex("Price") + .HasDatabaseName("ix_waifuinfo_price"); + + b.HasIndex("WaifuId") + .IsUnique() + .HasDatabaseName("ix_waifuinfo_waifuid"); + + b.ToTable("waifuinfo", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ItemEmoji") + .HasColumnType("longtext") + .HasColumnName("itememoji"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("WaifuInfoId") + .HasColumnType("int") + .HasColumnName("waifuinfoid"); + + b.HasKey("Id") + .HasName("pk_waifuitem"); + + b.HasIndex("WaifuInfoId") + .HasDatabaseName("ix_waifuitem_waifuinfoid"); + + b.ToTable("waifuitem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("NewId") + .HasColumnType("int") + .HasColumnName("newid"); + + b.Property("OldId") + .HasColumnType("int") + .HasColumnName("oldid"); + + b.Property("UpdateType") + .HasColumnType("int") + .HasColumnName("updatetype"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_waifuupdates"); + + b.HasIndex("NewId") + .HasDatabaseName("ix_waifuupdates_newid"); + + b.HasIndex("OldId") + .HasDatabaseName("ix_waifuupdates_oldid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_waifuupdates_userid"); + + b.ToTable("waifuupdates", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Forgiven") + .HasColumnType("tinyint(1)") + .HasColumnName("forgiven"); + + b.Property("ForgivenBy") + .HasColumnType("longtext") + .HasColumnName("forgivenby"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Moderator") + .HasColumnType("longtext") + .HasColumnName("moderator"); + + b.Property("Reason") + .HasColumnType("longtext") + .HasColumnName("reason"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("weight"); + + b.HasKey("Id") + .HasName("pk_warnings"); + + b.HasIndex("DateAdded") + .HasDatabaseName("ix_warnings_dateadded"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_warnings_guildid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_warnings_userid"); + + b.ToTable("warnings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WarningPunishment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Count") + .HasColumnType("int") + .HasColumnName("count"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Punishment") + .HasColumnType("int") + .HasColumnName("punishment"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("Time") + .HasColumnType("int") + .HasColumnName("time"); + + b.HasKey("Id") + .HasName("pk_warningpunishment"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_warningpunishment_guildconfigid"); + + b.ToTable("warningpunishment", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpCurrencyReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("int") + .HasColumnName("amount"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Level") + .HasColumnType("int") + .HasColumnName("level"); + + b.Property("XpSettingsId") + .HasColumnType("int") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_xpcurrencyreward"); + + b.HasIndex("XpSettingsId") + .HasDatabaseName("ix_xpcurrencyreward_xpsettingsid"); + + b.ToTable("xpcurrencyreward", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpRoleReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Level") + .HasColumnType("int") + .HasColumnName("level"); + + b.Property("Remove") + .HasColumnType("tinyint(1)") + .HasColumnName("remove"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("XpSettingsId") + .HasColumnType("int") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_xprolereward"); + + b.HasIndex("XpSettingsId", "Level") + .IsUnique() + .HasDatabaseName("ix_xprolereward_xpsettingsid_level"); + + b.ToTable("xprolereward", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("ServerExcluded") + .HasColumnType("tinyint(1)") + .HasColumnName("serverexcluded"); + + b.HasKey("Id") + .HasName("pk_xpsettings"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_xpsettings_guildconfigid"); + + b.ToTable("xpsettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Applicants") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubapplicants_clubs_clubid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubapplicants_discorduser_userid"); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Bans") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubbans_clubs_clubid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubbans_discorduser_userid"); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Owner") + .WithOne() + .HasForeignKey("EllieBot.Db.Models.ClubInfo", "OwnerId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_clubs_discorduser_ownerid"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Members") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction) + .HasConstraintName("fk_discorduser_clubs_clubid"); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FollowedStreams") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_followedstream_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiAltSetting", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithOne("AntiAltSetting") + .HasForeignKey("EllieBot.Services.Database.Models.AntiAltSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antialtsetting_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiRaidSetting", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithOne("AntiRaidSetting") + .HasForeignKey("EllieBot.Services.Database.Models.AntiRaidSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antiraidsetting_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamIgnore", b => + { + b.HasOne("EllieBot.Services.Database.Models.AntiSpamSetting", null) + .WithMany("IgnoredChannels") + .HasForeignKey("AntiSpamSettingId") + .HasConstraintName("fk_antispamignore_antispamsetting_antispamsettingid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamSetting", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithOne("AntiSpamSetting") + .HasForeignKey("EllieBot.Services.Database.Models.AntiSpamSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antispamsetting_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoTranslateUser", b => + { + b.HasOne("EllieBot.Services.Database.Models.AutoTranslateChannel", "Channel") + .WithMany("Users") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_autotranslateusers_autotranslatechannels_channelid"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CommandAlias", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("CommandAliases") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_commandalias_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CommandCooldown", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("CommandCooldowns") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_commandcooldown_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.DelMsgOnCmdChannel", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("DelMsgOnCmdChannels") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_delmsgoncmdchannel_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ExcludedItem", b => + { + b.HasOne("EllieBot.Services.Database.Models.XpSettings", null) + .WithMany("ExclusionList") + .HasForeignKey("XpSettingsId") + .HasConstraintName("fk_excludeditem_xpsettings_xpsettingsid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FeedSub", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithMany("FeedSubs") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_feedsub_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterChannelId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FilterInvitesChannelIds") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_filterchannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilteredWord", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FilteredWords") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_filteredword_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterLinksChannelId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FilterLinksChannelIds") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_filterlinkschannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterWordsChannelId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FilterWordsChannelIds") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_filterwordschannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GCChannelId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithMany("GenerateCurrencyChannelIds") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_gcchannelid_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GroupName", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithMany("SelfAssignableRoleGroupNames") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_groupname_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.IgnoredLogItem", b => + { + b.HasOne("EllieBot.Services.Database.Models.LogSetting", "LogSetting") + .WithMany("LogIgnores") + .HasForeignKey("LogSettingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ignoredlogchannels_logsettings_logsettingid"); + + b.Navigation("LogSetting"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.IgnoredVoicePresenceChannel", b => + { + b.HasOne("EllieBot.Services.Database.Models.LogSetting", "LogSetting") + .WithMany() + .HasForeignKey("LogSettingId") + .HasConstraintName("fk_ignoredvoicepresencechannels_logsettings_logsettingid"); + + b.Navigation("LogSetting"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MutedUserId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("MutedUsers") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_muteduserid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Permissionv2", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("Permissions") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_permissions_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PlaylistSong", b => + { + b.HasOne("EllieBot.Services.Database.Models.MusicPlaylist", null) + .WithMany("Songs") + .HasForeignKey("MusicPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_playlistsong_musicplaylists_musicplaylistid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PollAnswer", b => + { + b.HasOne("EllieBot.Services.Database.Models.Poll", null) + .WithMany("Answers") + .HasForeignKey("PollId") + .HasConstraintName("fk_pollanswer_poll_pollid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PollVote", b => + { + b.HasOne("EllieBot.Services.Database.Models.Poll", null) + .WithMany("Votes") + .HasForeignKey("PollId") + .HasConstraintName("fk_pollvote_poll_pollid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRole", b => + { + b.HasOne("EllieBot.Services.Database.Models.ReactionRoleMessage", null) + .WithMany("ReactionRoles") + .HasForeignKey("ReactionRoleMessageId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_reactionrole_reactionrolemessage_reactionrolemessageid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRoleMessage", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithMany("ReactionRoleMessages") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reactionrolemessage_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntry", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("ShopEntries") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_shopentry_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntryItem", b => + { + b.HasOne("EllieBot.Services.Database.Models.ShopEntry", null) + .WithMany("Items") + .HasForeignKey("ShopEntryId") + .HasConstraintName("fk_shopentryitem_shopentry_shopentryid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SlowmodeIgnoredRole", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredRoles") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_slowmodeignoredrole_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SlowmodeIgnoredUser", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredUsers") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_slowmodeignoreduser_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleBlacklistedUser", b => + { + b.HasOne("EllieBot.Services.Database.Models.StreamRoleSettings", null) + .WithMany("Blacklist") + .HasForeignKey("StreamRoleSettingsId") + .HasConstraintName("fk_streamroleblacklisteduser_streamrolesettings_streamrolesetti~"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleSettings", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithOne("StreamRole") + .HasForeignKey("EllieBot.Services.Database.Models.StreamRoleSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_streamrolesettings_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleWhitelistedUser", b => + { + b.HasOne("EllieBot.Services.Database.Models.StreamRoleSettings", null) + .WithMany("Whitelist") + .HasForeignKey("StreamRoleSettingsId") + .HasConstraintName("fk_streamrolewhitelisteduser_streamrolesettings_streamrolesetti~"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnbanTimer", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("UnbanTimer") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_unbantimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnmuteTimer", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("UnmuteTimers") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_unmutetimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnroleTimer", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("UnroleTimer") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_unroletimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.VcRoleInfo", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("VcRoleInfos") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_vcroleinfo_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Affinity") + .WithMany() + .HasForeignKey("AffinityId") + .HasConstraintName("fk_waifuinfo_discorduser_affinityid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Claimer") + .WithMany() + .HasForeignKey("ClaimerId") + .HasConstraintName("fk_waifuinfo_discorduser_claimerid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Waifu") + .WithOne() + .HasForeignKey("EllieBot.Services.Database.Models.WaifuInfo", "WaifuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_waifuinfo_discorduser_waifuid"); + + b.Navigation("Affinity"); + + b.Navigation("Claimer"); + + b.Navigation("Waifu"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuItem", b => + { + b.HasOne("EllieBot.Services.Database.Models.WaifuInfo", "WaifuInfo") + .WithMany("Items") + .HasForeignKey("WaifuInfoId") + .HasConstraintName("fk_waifuitem_waifuinfo_waifuinfoid"); + + b.Navigation("WaifuInfo"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuUpdate", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "New") + .WithMany() + .HasForeignKey("NewId") + .HasConstraintName("fk_waifuupdates_discorduser_newid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Old") + .WithMany() + .HasForeignKey("OldId") + .HasConstraintName("fk_waifuupdates_discorduser_oldid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_waifuupdates_discorduser_userid"); + + b.Navigation("New"); + + b.Navigation("Old"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WarningPunishment", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("WarnPunishments") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_warningpunishment_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpCurrencyReward", b => + { + b.HasOne("EllieBot.Services.Database.Models.XpSettings", "XpSettings") + .WithMany("CurrencyRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xpcurrencyreward_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpRoleReward", b => + { + b.HasOne("EllieBot.Services.Database.Models.XpSettings", "XpSettings") + .WithMany("RoleRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xprolereward_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpSettings", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithOne("XpSettings") + .HasForeignKey("EllieBot.Services.Database.Models.XpSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xpsettings_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Navigation("Applicants"); + + b.Navigation("Bans"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamSetting", b => + { + b.Navigation("IgnoredChannels"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoTranslateChannel", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GuildConfig", b => + { + b.Navigation("AntiAltSetting"); + + b.Navigation("AntiRaidSetting"); + + b.Navigation("AntiSpamSetting"); + + b.Navigation("CommandAliases"); + + b.Navigation("CommandCooldowns"); + + b.Navigation("DelMsgOnCmdChannels"); + + b.Navigation("FeedSubs"); + + b.Navigation("FilterInvitesChannelIds"); + + b.Navigation("FilterLinksChannelIds"); + + b.Navigation("FilterWordsChannelIds"); + + b.Navigation("FilteredWords"); + + b.Navigation("FollowedStreams"); + + b.Navigation("GenerateCurrencyChannelIds"); + + b.Navigation("MutedUsers"); + + b.Navigation("Permissions"); + + b.Navigation("ReactionRoleMessages"); + + b.Navigation("SelfAssignableRoleGroupNames"); + + b.Navigation("ShopEntries"); + + b.Navigation("SlowmodeIgnoredRoles"); + + b.Navigation("SlowmodeIgnoredUsers"); + + b.Navigation("StreamRole"); + + b.Navigation("UnbanTimer"); + + b.Navigation("UnmuteTimers"); + + b.Navigation("UnroleTimer"); + + b.Navigation("VcRoleInfos"); + + b.Navigation("WarnPunishments"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.LogSetting", b => + { + b.Navigation("LogIgnores"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MusicPlaylist", b => + { + b.Navigation("Songs"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Poll", b => + { + b.Navigation("Answers"); + + b.Navigation("Votes"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRoleMessage", b => + { + b.Navigation("ReactionRoles"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntry", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleSettings", b => + { + b.Navigation("Blacklist"); + + b.Navigation("Whitelist"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuInfo", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpSettings", b => + { + b.Navigation("CurrencyRewards"); + + b.Navigation("ExclusionList"); + + b.Navigation("RoleRewards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EllieBot/Migrations/Mysql/20220409170652_mysql-init.cs b/src/EllieBot/Migrations/Mysql/20220409170652_mysql-init.cs new file mode 100644 index 0000000..d7ab92d --- /dev/null +++ b/src/EllieBot/Migrations/Mysql/20220409170652_mysql-init.cs @@ -0,0 +1,2342 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EllieBot.Migrations.Mysql; + +public partial class mysqlinit : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "autocommands", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + commandtext = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + channelid = table.Column(type: "bigint unsigned", nullable: false), + channelname = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + guildid = table.Column(type: "bigint unsigned", nullable: true), + guildname = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + voicechannelid = table.Column(type: "bigint unsigned", nullable: true), + voicechannelname = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + interval = table.Column(type: "int", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_autocommands", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "autotranslatechannels", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + autodelete = table.Column(type: "tinyint(1)", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_autotranslatechannels", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "bantemplates", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + text = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_bantemplates", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "blacklist", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + itemid = table.Column(type: "bigint unsigned", nullable: false), + type = table.Column(type: "int", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_blacklist", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "currencytransactions", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + amount = table.Column(type: "bigint", nullable: false), + note = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + userid = table.Column(type: "bigint unsigned", nullable: false), + type = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + extra = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + otherid = table.Column(type: "bigint unsigned", nullable: true, defaultValueSql: "NULL"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_currencytransactions", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "discordpermoverrides", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + perm = table.Column(type: "bigint unsigned", nullable: false), + guildid = table.Column(type: "bigint unsigned", nullable: true), + command = table.Column(type: "varchar(255)", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_discordpermoverrides", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "expressions", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: true), + response = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + trigger = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + autodeletetrigger = table.Column(type: "tinyint(1)", nullable: false), + dmresponse = table.Column(type: "tinyint(1)", nullable: false), + containsanywhere = table.Column(type: "tinyint(1)", nullable: false), + allowtarget = table.Column(type: "tinyint(1)", nullable: false), + reactions = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_expressions", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "guildconfigs", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + prefix = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + deletemessageoncommand = table.Column(type: "tinyint(1)", nullable: false), + autoassignroleids = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + autodeletegreetmessagestimer = table.Column(type: "int", nullable: false), + autodeletebyemessagestimer = table.Column(type: "int", nullable: false), + greetmessagechannelid = table.Column(type: "bigint unsigned", nullable: false), + byemessagechannelid = table.Column(type: "bigint unsigned", nullable: false), + senddmgreetmessage = table.Column(type: "tinyint(1)", nullable: false), + dmgreetmessagetext = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + sendchannelgreetmessage = table.Column(type: "tinyint(1)", nullable: false), + channelgreetmessagetext = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + sendchannelbyemessage = table.Column(type: "tinyint(1)", nullable: false), + channelbyemessagetext = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + exclusiveselfassignedroles = table.Column(type: "tinyint(1)", nullable: false), + autodeleteselfassignedrolemessages = table.Column(type: "tinyint(1)", nullable: false), + verbosepermissions = table.Column(type: "tinyint(1)", nullable: false), + permissionrole = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + filterinvites = table.Column(type: "tinyint(1)", nullable: false), + filterlinks = table.Column(type: "tinyint(1)", nullable: false), + filterwords = table.Column(type: "tinyint(1)", nullable: false), + muterolename = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + cleverbotenabled = table.Column(type: "tinyint(1)", nullable: false), + locale = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + timezoneid = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + warningsinitialized = table.Column(type: "tinyint(1)", nullable: false), + gamevoicechannel = table.Column(type: "bigint unsigned", nullable: true), + verboseerrors = table.Column(type: "tinyint(1)", nullable: false), + notifystreamoffline = table.Column(type: "tinyint(1)", nullable: false), + warnexpirehours = table.Column(type: "int", nullable: false), + warnexpireaction = table.Column(type: "int", nullable: false), + sendboostmessage = table.Column(type: "tinyint(1)", nullable: false), + boostmessage = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + boostmessagechannelid = table.Column(type: "bigint unsigned", nullable: false), + boostmessagedeleteafter = table.Column(type: "int", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_guildconfigs", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "imageonlychannels", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_imageonlychannels", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "logsettings", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + logotherid = table.Column(type: "bigint unsigned", nullable: true), + messageupdatedid = table.Column(type: "bigint unsigned", nullable: true), + messagedeletedid = table.Column(type: "bigint unsigned", nullable: true), + userjoinedid = table.Column(type: "bigint unsigned", nullable: true), + userleftid = table.Column(type: "bigint unsigned", nullable: true), + userbannedid = table.Column(type: "bigint unsigned", nullable: true), + userunbannedid = table.Column(type: "bigint unsigned", nullable: true), + userupdatedid = table.Column(type: "bigint unsigned", nullable: true), + channelcreatedid = table.Column(type: "bigint unsigned", nullable: true), + channeldestroyedid = table.Column(type: "bigint unsigned", nullable: true), + channelupdatedid = table.Column(type: "bigint unsigned", nullable: true), + usermutedid = table.Column(type: "bigint unsigned", nullable: true), + loguserpresenceid = table.Column(type: "bigint unsigned", nullable: true), + logvoicepresenceid = table.Column(type: "bigint unsigned", nullable: true), + logvoicepresencettsid = table.Column(type: "bigint unsigned", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_logsettings", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "musicplayersettings", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + playerrepeat = table.Column(type: "int", nullable: false), + musicchannelid = table.Column(type: "bigint unsigned", nullable: true), + volume = table.Column(type: "int", nullable: false, defaultValue: 100), + autodisconnect = table.Column(type: "tinyint(1)", nullable: false), + qualitypreset = table.Column(type: "int", nullable: false), + autoplay = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_musicplayersettings", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "musicplaylists", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + name = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + author = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + authorid = table.Column(type: "bigint unsigned", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_musicplaylists", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "nsfwblacklistedtags", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + tag = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_nsfwblacklistedtags", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "plantedcurrency", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + amount = table.Column(type: "bigint", nullable: false), + password = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + guildid = table.Column(type: "bigint unsigned", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + userid = table.Column(type: "bigint unsigned", nullable: false), + messageid = table.Column(type: "bigint unsigned", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_plantedcurrency", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "poll", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + question = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_poll", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "quotes", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + keyword = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + authorname = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + authorid = table.Column(type: "bigint unsigned", nullable: false), + text = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_quotes", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "reminders", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + when = table.Column(type: "datetime(6)", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + serverid = table.Column(type: "bigint unsigned", nullable: false), + userid = table.Column(type: "bigint unsigned", nullable: false), + message = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + isprivate = table.Column(type: "tinyint(1)", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_reminders", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "repeaters", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + lastmessageid = table.Column(type: "bigint unsigned", nullable: true), + message = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + interval = table.Column(type: "time(6)", nullable: false), + starttimeofday = table.Column(type: "time(6)", nullable: true), + noredundant = table.Column(type: "tinyint(1)", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_repeaters", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "rewardedusers", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + patreonuserid = table.Column(type: "varchar(255)", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + amountrewardedthismonth = table.Column(type: "int", nullable: false), + lastreward = table.Column(type: "datetime(6)", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_rewardedusers", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "rotatingstatus", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + status = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + type = table.Column(type: "int", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_rotatingstatus", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "selfassignableroles", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + roleid = table.Column(type: "bigint unsigned", nullable: false), + group = table.Column(type: "int", nullable: false, defaultValue: 0), + levelrequirement = table.Column(type: "int", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_selfassignableroles", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "userxpstats", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + guildid = table.Column(type: "bigint unsigned", nullable: false), + xp = table.Column(type: "int", nullable: false), + awardedxp = table.Column(type: "int", nullable: false), + notifyonlevelup = table.Column(type: "int", nullable: false), + lastlevelup = table.Column(type: "datetime(6)", nullable: false, defaultValueSql: "(UTC_TIMESTAMP)"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_userxpstats", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "warnings", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + userid = table.Column(type: "bigint unsigned", nullable: false), + reason = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + forgiven = table.Column(type: "tinyint(1)", nullable: false), + forgivenby = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + moderator = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + weight = table.Column(type: "bigint", nullable: false, defaultValue: 1L), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_warnings", x => x.id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "autotranslateusers", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + channelid = table.Column(type: "int", nullable: false), + userid = table.Column(type: "bigint unsigned", nullable: false), + source = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + target = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_autotranslateusers", x => x.id); + table.UniqueConstraint("ak_autotranslateusers_channelid_userid", x => new { x.channelid, x.userid }); + table.ForeignKey( + name: "fk_autotranslateusers_autotranslatechannels_channelid", + column: x => x.channelid, + principalTable: "autotranslatechannels", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "antialtsetting", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: false), + minage = table.Column(type: "time(6)", nullable: false), + action = table.Column(type: "int", nullable: false), + actiondurationminutes = table.Column(type: "int", nullable: false), + roleid = table.Column(type: "bigint unsigned", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_antialtsetting", x => x.id); + table.ForeignKey( + name: "fk_antialtsetting_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "antiraidsetting", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: false), + userthreshold = table.Column(type: "int", nullable: false), + seconds = table.Column(type: "int", nullable: false), + action = table.Column(type: "int", nullable: false), + punishduration = table.Column(type: "int", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_antiraidsetting", x => x.id); + table.ForeignKey( + name: "fk_antiraidsetting_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "antispamsetting", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: false), + action = table.Column(type: "int", nullable: false), + messagethreshold = table.Column(type: "int", nullable: false), + mutetime = table.Column(type: "int", nullable: false), + roleid = table.Column(type: "bigint unsigned", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_antispamsetting", x => x.id); + table.ForeignKey( + name: "fk_antispamsetting_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "commandalias", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + trigger = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + mapping = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_commandalias", x => x.id); + table.ForeignKey( + name: "fk_commandalias_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "commandcooldown", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + seconds = table.Column(type: "int", nullable: false), + commandname = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_commandcooldown", x => x.id); + table.ForeignKey( + name: "fk_commandcooldown_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "delmsgoncmdchannel", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + channelid = table.Column(type: "bigint unsigned", nullable: false), + state = table.Column(type: "tinyint(1)", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_delmsgoncmdchannel", x => x.id); + table.ForeignKey( + name: "fk_delmsgoncmdchannel_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "feedsub", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + url = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_feedsub", x => x.id); + table.UniqueConstraint("ak_feedsub_guildconfigid_url", x => new { x.guildconfigid, x.url }); + table.ForeignKey( + name: "fk_feedsub_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "filterchannelid", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + channelid = table.Column(type: "bigint unsigned", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_filterchannelid", x => x.id); + table.ForeignKey( + name: "fk_filterchannelid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "filteredword", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + word = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_filteredword", x => x.id); + table.ForeignKey( + name: "fk_filteredword_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "filterlinkschannelid", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + channelid = table.Column(type: "bigint unsigned", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_filterlinkschannelid", x => x.id); + table.ForeignKey( + name: "fk_filterlinkschannelid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "filterwordschannelid", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + channelid = table.Column(type: "bigint unsigned", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_filterwordschannelid", x => x.id); + table.ForeignKey( + name: "fk_filterwordschannelid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "followedstream", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildid = table.Column(type: "bigint unsigned", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + username = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + type = table.Column(type: "int", nullable: false), + message = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_followedstream", x => x.id); + table.ForeignKey( + name: "fk_followedstream_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "gcchannelid", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: true), + channelid = table.Column(type: "bigint unsigned", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_gcchannelid", x => x.id); + table.ForeignKey( + name: "fk_gcchannelid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "groupname", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: false), + number = table.Column(type: "int", nullable: false), + name = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_groupname", x => x.id); + table.ForeignKey( + name: "fk_groupname_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "muteduserid", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_muteduserid", x => x.id); + table.ForeignKey( + name: "fk_muteduserid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "permissions", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: true), + index = table.Column(type: "int", nullable: false), + primarytarget = table.Column(type: "int", nullable: false), + primarytargetid = table.Column(type: "bigint unsigned", nullable: false), + secondarytarget = table.Column(type: "int", nullable: false), + secondarytargetname = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + iscustomcommand = table.Column(type: "tinyint(1)", nullable: false), + state = table.Column(type: "tinyint(1)", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_permissions", x => x.id); + table.ForeignKey( + name: "fk_permissions_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "reactionrolemessage", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + index = table.Column(type: "int", nullable: false), + guildconfigid = table.Column(type: "int", nullable: false), + channelid = table.Column(type: "bigint unsigned", nullable: false), + messageid = table.Column(type: "bigint unsigned", nullable: false), + exclusive = table.Column(type: "tinyint(1)", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_reactionrolemessage", x => x.id); + table.ForeignKey( + name: "fk_reactionrolemessage_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "shopentry", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + index = table.Column(type: "int", nullable: false), + price = table.Column(type: "int", nullable: false), + name = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + authorid = table.Column(type: "bigint unsigned", nullable: false), + type = table.Column(type: "int", nullable: false), + rolename = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + roleid = table.Column(type: "bigint unsigned", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_shopentry", x => x.id); + table.ForeignKey( + name: "fk_shopentry_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "slowmodeignoredrole", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + roleid = table.Column(type: "bigint unsigned", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_slowmodeignoredrole", x => x.id); + table.ForeignKey( + name: "fk_slowmodeignoredrole_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "slowmodeignoreduser", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_slowmodeignoreduser", x => x.id); + table.ForeignKey( + name: "fk_slowmodeignoreduser_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "streamrolesettings", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: false), + enabled = table.Column(type: "tinyint(1)", nullable: false), + addroleid = table.Column(type: "bigint unsigned", nullable: false), + fromroleid = table.Column(type: "bigint unsigned", nullable: false), + keyword = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_streamrolesettings", x => x.id); + table.ForeignKey( + name: "fk_streamrolesettings_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "unbantimer", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + unbanat = table.Column(type: "datetime(6)", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_unbantimer", x => x.id); + table.ForeignKey( + name: "fk_unbantimer_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "unmutetimer", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + unmuteat = table.Column(type: "datetime(6)", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_unmutetimer", x => x.id); + table.ForeignKey( + name: "fk_unmutetimer_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "unroletimer", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + roleid = table.Column(type: "bigint unsigned", nullable: false), + unbanat = table.Column(type: "datetime(6)", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_unroletimer", x => x.id); + table.ForeignKey( + name: "fk_unroletimer_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "vcroleinfo", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + voicechannelid = table.Column(type: "bigint unsigned", nullable: false), + roleid = table.Column(type: "bigint unsigned", nullable: false), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_vcroleinfo", x => x.id); + table.ForeignKey( + name: "fk_vcroleinfo_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "warningpunishment", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + count = table.Column(type: "int", nullable: false), + punishment = table.Column(type: "int", nullable: false), + time = table.Column(type: "int", nullable: false), + roleid = table.Column(type: "bigint unsigned", nullable: true), + guildconfigid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_warningpunishment", x => x.id); + table.ForeignKey( + name: "fk_warningpunishment_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "xpsettings", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + guildconfigid = table.Column(type: "int", nullable: false), + serverexcluded = table.Column(type: "tinyint(1)", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_xpsettings", x => x.id); + table.ForeignKey( + name: "fk_xpsettings_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ignoredlogchannels", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + logsettingid = table.Column(type: "int", nullable: false), + logitemid = table.Column(type: "bigint unsigned", nullable: false), + itemtype = table.Column(type: "int", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_ignoredlogchannels", x => x.id); + table.ForeignKey( + name: "fk_ignoredlogchannels_logsettings_logsettingid", + column: x => x.logsettingid, + principalTable: "logsettings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ignoredvoicepresencechannels", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + logsettingid = table.Column(type: "int", nullable: true), + channelid = table.Column(type: "bigint unsigned", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_ignoredvoicepresencechannels", x => x.id); + table.ForeignKey( + name: "fk_ignoredvoicepresencechannels_logsettings_logsettingid", + column: x => x.logsettingid, + principalTable: "logsettings", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "playlistsong", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + provider = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + providertype = table.Column(type: "int", nullable: false), + title = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + uri = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + query = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + musicplaylistid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_playlistsong", x => x.id); + table.ForeignKey( + name: "fk_playlistsong_musicplaylists_musicplaylistid", + column: x => x.musicplaylistid, + principalTable: "musicplaylists", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "pollanswer", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + index = table.Column(type: "int", nullable: false), + text = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + pollid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_pollanswer", x => x.id); + table.ForeignKey( + name: "fk_pollanswer_poll_pollid", + column: x => x.pollid, + principalTable: "poll", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "pollvote", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + voteindex = table.Column(type: "int", nullable: false), + pollid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_pollvote", x => x.id); + table.ForeignKey( + name: "fk_pollvote_poll_pollid", + column: x => x.pollid, + principalTable: "poll", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "antispamignore", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + channelid = table.Column(type: "bigint unsigned", nullable: false), + antispamsettingid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_antispamignore", x => x.id); + table.ForeignKey( + name: "fk_antispamignore_antispamsetting_antispamsettingid", + column: x => x.antispamsettingid, + principalTable: "antispamsetting", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "reactionrole", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + emotename = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + roleid = table.Column(type: "bigint unsigned", nullable: false), + reactionrolemessageid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_reactionrole", x => x.id); + table.ForeignKey( + name: "fk_reactionrole_reactionrolemessage_reactionrolemessageid", + column: x => x.reactionrolemessageid, + principalTable: "reactionrolemessage", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "shopentryitem", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + text = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + shopentryid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_shopentryitem", x => x.id); + table.ForeignKey( + name: "fk_shopentryitem_shopentry_shopentryid", + column: x => x.shopentryid, + principalTable: "shopentry", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "streamroleblacklisteduser", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + username = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + streamrolesettingsid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_streamroleblacklisteduser", x => x.id); + table.ForeignKey( + name: "fk_streamroleblacklisteduser_streamrolesettings_streamrolesetti~", + column: x => x.streamrolesettingsid, + principalTable: "streamrolesettings", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "streamrolewhitelisteduser", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + username = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + streamrolesettingsid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_streamrolewhitelisteduser", x => x.id); + table.ForeignKey( + name: "fk_streamrolewhitelisteduser_streamrolesettings_streamrolesetti~", + column: x => x.streamrolesettingsid, + principalTable: "streamrolesettings", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "excludeditem", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + itemid = table.Column(type: "bigint unsigned", nullable: false), + itemtype = table.Column(type: "int", nullable: false), + xpsettingsid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_excludeditem", x => x.id); + table.ForeignKey( + name: "fk_excludeditem_xpsettings_xpsettingsid", + column: x => x.xpsettingsid, + principalTable: "xpsettings", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "xpcurrencyreward", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + xpsettingsid = table.Column(type: "int", nullable: false), + level = table.Column(type: "int", nullable: false), + amount = table.Column(type: "int", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_xpcurrencyreward", x => x.id); + table.ForeignKey( + name: "fk_xpcurrencyreward_xpsettings_xpsettingsid", + column: x => x.xpsettingsid, + principalTable: "xpsettings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "xprolereward", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + xpsettingsid = table.Column(type: "int", nullable: false), + level = table.Column(type: "int", nullable: false), + roleid = table.Column(type: "bigint unsigned", nullable: false), + remove = table.Column(type: "tinyint(1)", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_xprolereward", x => x.id); + table.ForeignKey( + name: "fk_xprolereward_xpsettings_xpsettingsid", + column: x => x.xpsettingsid, + principalTable: "xpsettings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "clubapplicants", + columns: table => new + { + clubid = table.Column(type: "int", nullable: false), + userid = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_clubapplicants", x => new { x.clubid, x.userid }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "clubbans", + columns: table => new + { + clubid = table.Column(type: "int", nullable: false), + userid = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_clubbans", x => new { x.clubid, x.userid }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "clubs", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + name = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_bin") + .Annotation("MySql:CharSet", "utf8mb4"), + description = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + imageurl = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + xp = table.Column(type: "int", nullable: false), + ownerid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_clubs", x => x.id); + table.UniqueConstraint("ak_clubs_name", x => x.name); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "discorduser", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "bigint unsigned", nullable: false), + username = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + discriminator = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + avatarid = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + clubid = table.Column(type: "int", nullable: true), + isclubadmin = table.Column(type: "tinyint(1)", nullable: false, defaultValue: false), + totalxp = table.Column(type: "int", nullable: false, defaultValue: 0), + lastlevelup = table.Column(type: "datetime(6)", nullable: false, defaultValueSql: "(UTC_TIMESTAMP)"), + lastxpgain = table.Column(type: "datetime(6)", nullable: false, defaultValueSql: "(UTC_TIMESTAMP - INTERVAL 1 year)"), + notifyonlevelup = table.Column(type: "int", nullable: false, defaultValue: 0), + currencyamount = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_discorduser", x => x.id); + table.UniqueConstraint("ak_discorduser_userid", x => x.userid); + table.ForeignKey( + name: "fk_discorduser_clubs_clubid", + column: x => x.clubid, + principalTable: "clubs", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "waifuinfo", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + waifuid = table.Column(type: "int", nullable: false), + claimerid = table.Column(type: "int", nullable: true), + affinityid = table.Column(type: "int", nullable: true), + price = table.Column(type: "bigint", nullable: false), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_waifuinfo", x => x.id); + table.ForeignKey( + name: "fk_waifuinfo_discorduser_affinityid", + column: x => x.affinityid, + principalTable: "discorduser", + principalColumn: "id"); + table.ForeignKey( + name: "fk_waifuinfo_discorduser_claimerid", + column: x => x.claimerid, + principalTable: "discorduser", + principalColumn: "id"); + table.ForeignKey( + name: "fk_waifuinfo_discorduser_waifuid", + column: x => x.waifuid, + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "waifuupdates", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + userid = table.Column(type: "int", nullable: false), + updatetype = table.Column(type: "int", nullable: false), + oldid = table.Column(type: "int", nullable: true), + newid = table.Column(type: "int", nullable: true), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_waifuupdates", x => x.id); + table.ForeignKey( + name: "fk_waifuupdates_discorduser_newid", + column: x => x.newid, + principalTable: "discorduser", + principalColumn: "id"); + table.ForeignKey( + name: "fk_waifuupdates_discorduser_oldid", + column: x => x.oldid, + principalTable: "discorduser", + principalColumn: "id"); + table.ForeignKey( + name: "fk_waifuupdates_discorduser_userid", + column: x => x.userid, + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "waifuitem", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + waifuinfoid = table.Column(type: "int", nullable: true), + itememoji = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + name = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + dateadded = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_waifuitem", x => x.id); + table.ForeignKey( + name: "fk_waifuitem_waifuinfo_waifuinfoid", + column: x => x.waifuinfoid, + principalTable: "waifuinfo", + principalColumn: "id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "ix_antialtsetting_guildconfigid", + table: "antialtsetting", + column: "guildconfigid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_antiraidsetting_guildconfigid", + table: "antiraidsetting", + column: "guildconfigid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_antispamignore_antispamsettingid", + table: "antispamignore", + column: "antispamsettingid"); + + migrationBuilder.CreateIndex( + name: "ix_antispamsetting_guildconfigid", + table: "antispamsetting", + column: "guildconfigid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_autotranslatechannels_channelid", + table: "autotranslatechannels", + column: "channelid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_autotranslatechannels_guildid", + table: "autotranslatechannels", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_bantemplates_guildid", + table: "bantemplates", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_clubapplicants_userid", + table: "clubapplicants", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_clubbans_userid", + table: "clubbans", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_clubs_ownerid", + table: "clubs", + column: "ownerid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_commandalias_guildconfigid", + table: "commandalias", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_commandcooldown_guildconfigid", + table: "commandcooldown", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_currencytransactions_userid", + table: "currencytransactions", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_delmsgoncmdchannel_guildconfigid", + table: "delmsgoncmdchannel", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_discordpermoverrides_guildid_command", + table: "discordpermoverrides", + columns: new[] { "guildid", "command" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_discorduser_clubid", + table: "discorduser", + column: "clubid"); + + migrationBuilder.CreateIndex( + name: "ix_discorduser_currencyamount", + table: "discorduser", + column: "currencyamount"); + + migrationBuilder.CreateIndex( + name: "ix_discorduser_totalxp", + table: "discorduser", + column: "totalxp"); + + migrationBuilder.CreateIndex( + name: "ix_discorduser_userid", + table: "discorduser", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_excludeditem_xpsettingsid", + table: "excludeditem", + column: "xpsettingsid"); + + migrationBuilder.CreateIndex( + name: "ix_filterchannelid_guildconfigid", + table: "filterchannelid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_filteredword_guildconfigid", + table: "filteredword", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_filterlinkschannelid_guildconfigid", + table: "filterlinkschannelid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_filterwordschannelid_guildconfigid", + table: "filterwordschannelid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_followedstream_guildconfigid", + table: "followedstream", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_gcchannelid_guildconfigid", + table: "gcchannelid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_groupname_guildconfigid_number", + table: "groupname", + columns: new[] { "guildconfigid", "number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_guildconfigs_guildid", + table: "guildconfigs", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_guildconfigs_warnexpirehours", + table: "guildconfigs", + column: "warnexpirehours"); + + migrationBuilder.CreateIndex( + name: "ix_ignoredlogchannels_logsettingid_logitemid_itemtype", + table: "ignoredlogchannels", + columns: new[] { "logsettingid", "logitemid", "itemtype" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_ignoredvoicepresencechannels_logsettingid", + table: "ignoredvoicepresencechannels", + column: "logsettingid"); + + migrationBuilder.CreateIndex( + name: "ix_imageonlychannels_channelid", + table: "imageonlychannels", + column: "channelid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_logsettings_guildid", + table: "logsettings", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_musicplayersettings_guildid", + table: "musicplayersettings", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_muteduserid_guildconfigid", + table: "muteduserid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_nsfwblacklistedtags_guildid", + table: "nsfwblacklistedtags", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_permissions_guildconfigid", + table: "permissions", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_plantedcurrency_channelid", + table: "plantedcurrency", + column: "channelid"); + + migrationBuilder.CreateIndex( + name: "ix_plantedcurrency_messageid", + table: "plantedcurrency", + column: "messageid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_playlistsong_musicplaylistid", + table: "playlistsong", + column: "musicplaylistid"); + + migrationBuilder.CreateIndex( + name: "ix_poll_guildid", + table: "poll", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_pollanswer_pollid", + table: "pollanswer", + column: "pollid"); + + migrationBuilder.CreateIndex( + name: "ix_pollvote_pollid", + table: "pollvote", + column: "pollid"); + + migrationBuilder.CreateIndex( + name: "ix_quotes_guildid", + table: "quotes", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_quotes_keyword", + table: "quotes", + column: "keyword"); + + migrationBuilder.CreateIndex( + name: "ix_reactionrole_reactionrolemessageid", + table: "reactionrole", + column: "reactionrolemessageid"); + + migrationBuilder.CreateIndex( + name: "ix_reactionrolemessage_guildconfigid", + table: "reactionrolemessage", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_reminders_when", + table: "reminders", + column: "when"); + + migrationBuilder.CreateIndex( + name: "ix_rewardedusers_patreonuserid", + table: "rewardedusers", + column: "patreonuserid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_selfassignableroles_guildid_roleid", + table: "selfassignableroles", + columns: new[] { "guildid", "roleid" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_shopentry_guildconfigid", + table: "shopentry", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_shopentryitem_shopentryid", + table: "shopentryitem", + column: "shopentryid"); + + migrationBuilder.CreateIndex( + name: "ix_slowmodeignoredrole_guildconfigid", + table: "slowmodeignoredrole", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_slowmodeignoreduser_guildconfigid", + table: "slowmodeignoreduser", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_streamroleblacklisteduser_streamrolesettingsid", + table: "streamroleblacklisteduser", + column: "streamrolesettingsid"); + + migrationBuilder.CreateIndex( + name: "ix_streamrolesettings_guildconfigid", + table: "streamrolesettings", + column: "guildconfigid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_streamrolewhitelisteduser_streamrolesettingsid", + table: "streamrolewhitelisteduser", + column: "streamrolesettingsid"); + + migrationBuilder.CreateIndex( + name: "ix_unbantimer_guildconfigid", + table: "unbantimer", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_unmutetimer_guildconfigid", + table: "unmutetimer", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_unroletimer_guildconfigid", + table: "unroletimer", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_awardedxp", + table: "userxpstats", + column: "awardedxp"); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_guildid", + table: "userxpstats", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_userid", + table: "userxpstats", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_userid_guildid", + table: "userxpstats", + columns: new[] { "userid", "guildid" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_xp", + table: "userxpstats", + column: "xp"); + + migrationBuilder.CreateIndex( + name: "ix_vcroleinfo_guildconfigid", + table: "vcroleinfo", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuinfo_affinityid", + table: "waifuinfo", + column: "affinityid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuinfo_claimerid", + table: "waifuinfo", + column: "claimerid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuinfo_price", + table: "waifuinfo", + column: "price"); + + migrationBuilder.CreateIndex( + name: "ix_waifuinfo_waifuid", + table: "waifuinfo", + column: "waifuid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_waifuitem_waifuinfoid", + table: "waifuitem", + column: "waifuinfoid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuupdates_newid", + table: "waifuupdates", + column: "newid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuupdates_oldid", + table: "waifuupdates", + column: "oldid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuupdates_userid", + table: "waifuupdates", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_warningpunishment_guildconfigid", + table: "warningpunishment", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_warnings_dateadded", + table: "warnings", + column: "dateadded"); + + migrationBuilder.CreateIndex( + name: "ix_warnings_guildid", + table: "warnings", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_warnings_userid", + table: "warnings", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_xpcurrencyreward_xpsettingsid", + table: "xpcurrencyreward", + column: "xpsettingsid"); + + migrationBuilder.CreateIndex( + name: "ix_xprolereward_xpsettingsid_level", + table: "xprolereward", + columns: new[] { "xpsettingsid", "level" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_xpsettings_guildconfigid", + table: "xpsettings", + column: "guildconfigid", + unique: true); + + migrationBuilder.AddForeignKey( + name: "fk_clubapplicants_clubs_clubid", + table: "clubapplicants", + column: "clubid", + principalTable: "clubs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_clubapplicants_discorduser_userid", + table: "clubapplicants", + column: "userid", + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_clubbans_clubs_clubid", + table: "clubbans", + column: "clubid", + principalTable: "clubs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_clubbans_discorduser_userid", + table: "clubbans", + column: "userid", + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_clubs_discorduser_ownerid", + table: "clubs", + column: "ownerid", + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_discorduser_clubs_clubid", + table: "discorduser"); + + migrationBuilder.DropTable( + name: "antialtsetting"); + + migrationBuilder.DropTable( + name: "antiraidsetting"); + + migrationBuilder.DropTable( + name: "antispamignore"); + + migrationBuilder.DropTable( + name: "autocommands"); + + migrationBuilder.DropTable( + name: "autotranslateusers"); + + migrationBuilder.DropTable( + name: "bantemplates"); + + migrationBuilder.DropTable( + name: "blacklist"); + + migrationBuilder.DropTable( + name: "clubapplicants"); + + migrationBuilder.DropTable( + name: "clubbans"); + + migrationBuilder.DropTable( + name: "commandalias"); + + migrationBuilder.DropTable( + name: "commandcooldown"); + + migrationBuilder.DropTable( + name: "currencytransactions"); + + migrationBuilder.DropTable( + name: "delmsgoncmdchannel"); + + migrationBuilder.DropTable( + name: "discordpermoverrides"); + + migrationBuilder.DropTable( + name: "excludeditem"); + + migrationBuilder.DropTable( + name: "expressions"); + + migrationBuilder.DropTable( + name: "feedsub"); + + migrationBuilder.DropTable( + name: "filterchannelid"); + + migrationBuilder.DropTable( + name: "filteredword"); + + migrationBuilder.DropTable( + name: "filterlinkschannelid"); + + migrationBuilder.DropTable( + name: "filterwordschannelid"); + + migrationBuilder.DropTable( + name: "followedstream"); + + migrationBuilder.DropTable( + name: "gcchannelid"); + + migrationBuilder.DropTable( + name: "groupname"); + + migrationBuilder.DropTable( + name: "ignoredlogchannels"); + + migrationBuilder.DropTable( + name: "ignoredvoicepresencechannels"); + + migrationBuilder.DropTable( + name: "imageonlychannels"); + + migrationBuilder.DropTable( + name: "musicplayersettings"); + + migrationBuilder.DropTable( + name: "muteduserid"); + + migrationBuilder.DropTable( + name: "nsfwblacklistedtags"); + + migrationBuilder.DropTable( + name: "permissions"); + + migrationBuilder.DropTable( + name: "plantedcurrency"); + + migrationBuilder.DropTable( + name: "playlistsong"); + + migrationBuilder.DropTable( + name: "pollanswer"); + + migrationBuilder.DropTable( + name: "pollvote"); + + migrationBuilder.DropTable( + name: "quotes"); + + migrationBuilder.DropTable( + name: "reactionrole"); + + migrationBuilder.DropTable( + name: "reminders"); + + migrationBuilder.DropTable( + name: "repeaters"); + + migrationBuilder.DropTable( + name: "rewardedusers"); + + migrationBuilder.DropTable( + name: "rotatingstatus"); + + migrationBuilder.DropTable( + name: "selfassignableroles"); + + migrationBuilder.DropTable( + name: "shopentryitem"); + + migrationBuilder.DropTable( + name: "slowmodeignoredrole"); + + migrationBuilder.DropTable( + name: "slowmodeignoreduser"); + + migrationBuilder.DropTable( + name: "streamroleblacklisteduser"); + + migrationBuilder.DropTable( + name: "streamrolewhitelisteduser"); + + migrationBuilder.DropTable( + name: "unbantimer"); + + migrationBuilder.DropTable( + name: "unmutetimer"); + + migrationBuilder.DropTable( + name: "unroletimer"); + + migrationBuilder.DropTable( + name: "userxpstats"); + + migrationBuilder.DropTable( + name: "vcroleinfo"); + + migrationBuilder.DropTable( + name: "waifuitem"); + + migrationBuilder.DropTable( + name: "waifuupdates"); + + migrationBuilder.DropTable( + name: "warningpunishment"); + + migrationBuilder.DropTable( + name: "warnings"); + + migrationBuilder.DropTable( + name: "xpcurrencyreward"); + + migrationBuilder.DropTable( + name: "xprolereward"); + + migrationBuilder.DropTable( + name: "antispamsetting"); + + migrationBuilder.DropTable( + name: "autotranslatechannels"); + + migrationBuilder.DropTable( + name: "logsettings"); + + migrationBuilder.DropTable( + name: "musicplaylists"); + + migrationBuilder.DropTable( + name: "poll"); + + migrationBuilder.DropTable( + name: "reactionrolemessage"); + + migrationBuilder.DropTable( + name: "shopentry"); + + migrationBuilder.DropTable( + name: "streamrolesettings"); + + migrationBuilder.DropTable( + name: "waifuinfo"); + + migrationBuilder.DropTable( + name: "xpsettings"); + + migrationBuilder.DropTable( + name: "guildconfigs"); + + migrationBuilder.DropTable( + name: "clubs"); + + migrationBuilder.DropTable( + name: "discorduser"); + } +} diff --git a/src/EllieBot/Migrations/Mysql/MysqlContextModelSnapshot.cs b/src/EllieBot/Migrations/Mysql/MysqlContextModelSnapshot.cs new file mode 100644 index 0000000..9a6e160 --- /dev/null +++ b/src/EllieBot/Migrations/Mysql/MysqlContextModelSnapshot.cs @@ -0,0 +1,3816 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using EllieBot.Db; + +#nullable disable + +namespace EllieBot.Migrations.Mysql +{ + [DbContext(typeof(MysqlContext))] + partial class MysqlContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("EllieBot.Db.Models.AntiAltSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("int") + .HasColumnName("action"); + + b.Property("ActionDurationMinutes") + .HasColumnType("int") + .HasColumnName("actiondurationminutes"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("MinAge") + .HasColumnType("time(6)") + .HasColumnName("minage"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_antialtsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antialtsetting_guildconfigid"); + + b.ToTable("antialtsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiRaidSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("int") + .HasColumnName("action"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("PunishDuration") + .HasColumnType("int") + .HasColumnName("punishduration"); + + b.Property("Seconds") + .HasColumnType("int") + .HasColumnName("seconds"); + + b.Property("UserThreshold") + .HasColumnType("int") + .HasColumnName("userthreshold"); + + b.HasKey("Id") + .HasName("pk_antiraidsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antiraidsetting_guildconfigid"); + + b.ToTable("antiraidsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamIgnore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AntiSpamSettingId") + .HasColumnType("int") + .HasColumnName("antispamsettingid"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.HasKey("Id") + .HasName("pk_antispamignore"); + + b.HasIndex("AntiSpamSettingId") + .HasDatabaseName("ix_antispamignore_antispamsettingid"); + + b.ToTable("antispamignore", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("int") + .HasColumnName("action"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("MessageThreshold") + .HasColumnType("int") + .HasColumnName("messagethreshold"); + + b.Property("MuteTime") + .HasColumnType("int") + .HasColumnName("mutetime"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_antispamsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antispamsetting_guildconfigid"); + + b.ToTable("antispamsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ArchivedTodoListModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_todosarchive"); + + b.ToTable("todosarchive", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("ChannelName") + .HasColumnType("longtext") + .HasColumnName("channelname"); + + b.Property("CommandText") + .HasColumnType("longtext") + .HasColumnName("commandtext"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("GuildName") + .HasColumnType("longtext") + .HasColumnName("guildname"); + + b.Property("Interval") + .HasColumnType("int") + .HasColumnName("interval"); + + b.Property("VoiceChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("voicechannelid"); + + b.Property("VoiceChannelName") + .HasColumnType("longtext") + .HasColumnName("voicechannelname"); + + b.HasKey("Id") + .HasName("pk_autocommands"); + + b.ToTable("autocommands", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoPublishChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.HasKey("Id") + .HasName("pk_autopublishchannel"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_autopublishchannel_guildid"); + + b.ToTable("autopublishchannel", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AutoDelete") + .HasColumnType("tinyint(1)") + .HasColumnName("autodelete"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.HasKey("Id") + .HasName("pk_autotranslatechannels"); + + b.HasIndex("ChannelId") + .IsUnique() + .HasDatabaseName("ix_autotranslatechannels_channelid"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_autotranslatechannels_guildid"); + + b.ToTable("autotranslatechannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("int") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Source") + .HasColumnType("longtext") + .HasColumnName("source"); + + b.Property("Target") + .HasColumnType("longtext") + .HasColumnName("target"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_autotranslateusers"); + + b.HasAlternateKey("ChannelId", "UserId") + .HasName("ak_autotranslateusers_channelid_userid"); + + b.ToTable("autotranslateusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BanTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("PruneDays") + .HasColumnType("int") + .HasColumnName("prunedays"); + + b.Property("Text") + .HasColumnType("longtext") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_bantemplates"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_bantemplates_guildid"); + + b.ToTable("bantemplates", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BankUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Balance") + .HasColumnType("bigint") + .HasColumnName("balance"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_bankusers"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_bankusers_userid"); + + b.ToTable("bankusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BlacklistEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ItemId") + .HasColumnType("bigint unsigned") + .HasColumnName("itemid"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_blacklist"); + + b.ToTable("blacklist", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.Property("ClubId") + .HasColumnType("int") + .HasColumnName("clubid"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("userid"); + + b.HasKey("ClubId", "UserId") + .HasName("pk_clubapplicants"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_clubapplicants_userid"); + + b.ToTable("clubapplicants", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.Property("ClubId") + .HasColumnType("int") + .HasColumnName("clubid"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("userid"); + + b.HasKey("ClubId", "UserId") + .HasName("pk_clubbans"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_clubbans_userid"); + + b.ToTable("clubbans", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Description") + .HasColumnType("longtext") + .HasColumnName("description"); + + b.Property("ImageUrl") + .HasColumnType("longtext") + .HasColumnName("imageurl"); + + b.Property("Name") + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasColumnName("name") + .UseCollation("utf8mb4_bin"); + + b.Property("OwnerId") + .HasColumnType("int") + .HasColumnName("ownerid"); + + b.Property("Xp") + .HasColumnType("int") + .HasColumnName("xp"); + + b.HasKey("Id") + .HasName("pk_clubs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_clubs_name"); + + b.HasIndex("OwnerId") + .IsUnique() + .HasDatabaseName("ix_clubs_ownerid"); + + b.ToTable("clubs", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Mapping") + .HasColumnType("longtext") + .HasColumnName("mapping"); + + b.Property("Trigger") + .HasColumnType("longtext") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("pk_commandalias"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_commandalias_guildconfigid"); + + b.ToTable("commandalias", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandCooldown", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CommandName") + .HasColumnType("longtext") + .HasColumnName("commandname"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Seconds") + .HasColumnType("int") + .HasColumnName("seconds"); + + b.HasKey("Id") + .HasName("pk_commandcooldown"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_commandcooldown_guildconfigid"); + + b.ToTable("commandcooldown", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CurrencyTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("extra"); + + b.Property("Note") + .HasColumnType("longtext") + .HasColumnName("note"); + + b.Property("OtherId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint unsigned") + .HasColumnName("otherid") + .HasDefaultValueSql("NULL"); + + b.Property("Type") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_currencytransactions"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_currencytransactions_userid"); + + b.ToTable("currencytransactions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DelMsgOnCmdChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("State") + .HasColumnType("tinyint(1)") + .HasColumnName("state"); + + b.HasKey("Id") + .HasName("pk_delmsgoncmdchannel"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_delmsgoncmdchannel_guildconfigid"); + + b.ToTable("delmsgoncmdchannel", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordPermOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Command") + .HasColumnType("varchar(255)") + .HasColumnName("command"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Perm") + .HasColumnType("bigint unsigned") + .HasColumnName("perm"); + + b.HasKey("Id") + .HasName("pk_discordpermoverrides"); + + b.HasIndex("GuildId", "Command") + .IsUnique() + .HasDatabaseName("ix_discordpermoverrides_guildid_command"); + + b.ToTable("discordpermoverrides", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AvatarId") + .HasColumnType("longtext") + .HasColumnName("avatarid"); + + b.Property("ClubId") + .HasColumnType("int") + .HasColumnName("clubid"); + + b.Property("CurrencyAmount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("currencyamount"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Discriminator") + .HasColumnType("longtext") + .HasColumnName("discriminator"); + + b.Property("IsClubAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("isclubadmin"); + + b.Property("NotifyOnLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("notifyonlevelup"); + + b.Property("TotalXp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("totalxp"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("longtext") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_discorduser"); + + b.HasAlternateKey("UserId") + .HasName("ak_discorduser_userid"); + + b.HasIndex("ClubId") + .HasDatabaseName("ix_discorduser_clubid"); + + b.HasIndex("CurrencyAmount") + .HasDatabaseName("ix_discorduser_currencyamount"); + + b.HasIndex("TotalXp") + .HasDatabaseName("ix_discorduser_totalxp"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_discorduser_userid"); + + b.ToTable("discorduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ItemId") + .HasColumnType("bigint unsigned") + .HasColumnName("itemid"); + + b.Property("ItemType") + .HasColumnType("int") + .HasColumnName("itemtype"); + + b.Property("XpSettingsId") + .HasColumnType("int") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_excludeditem"); + + b.HasIndex("XpSettingsId") + .HasDatabaseName("ix_excludeditem_xpsettingsid"); + + b.ToTable("excludeditem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Message") + .HasColumnType("longtext") + .HasColumnName("message"); + + b.Property("Url") + .IsRequired() + .HasColumnType("varchar(255)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_feedsub"); + + b.HasAlternateKey("GuildConfigId", "Url") + .HasName("ak_feedsub_guildconfigid_url"); + + b.ToTable("feedsub", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterchannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterchannelid_guildconfigid"); + + b.ToTable("filterchannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterLinksChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterlinkschannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterlinkschannelid_guildconfigid"); + + b.ToTable("filterlinkschannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterWordsChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterwordschannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterwordschannelid_guildconfigid"); + + b.ToTable("filterwordschannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilteredWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Word") + .HasColumnType("longtext") + .HasColumnName("word"); + + b.HasKey("Id") + .HasName("pk_filteredword"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filteredword_guildconfigid"); + + b.ToTable("filteredword", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Message") + .HasColumnType("longtext") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.Property("Username") + .HasColumnType("longtext") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_followedstream"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_followedstream_guildconfigid"); + + b.ToTable("followedstream", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GCChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_gcchannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_gcchannelid_guildconfigid"); + + b.ToTable("gcchannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GamblingStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Bet") + .HasColumnType("decimal(65,30)") + .HasColumnName("bet"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Feature") + .HasColumnType("varchar(255)") + .HasColumnName("feature"); + + b.Property("PaidOut") + .HasColumnType("decimal(65,30)") + .HasColumnName("paidout"); + + b.HasKey("Id") + .HasName("pk_gamblingstats"); + + b.HasIndex("Feature") + .IsUnique() + .HasDatabaseName("ix_gamblingstats_feature"); + + b.ToTable("gamblingstats", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)") + .HasColumnName("endsat"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Message") + .HasColumnType("longtext") + .HasColumnName("message"); + + b.Property("MessageId") + .HasColumnType("bigint unsigned") + .HasColumnName("messageid"); + + b.HasKey("Id") + .HasName("pk_giveawaymodel"); + + b.ToTable("giveawaymodel", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("GiveawayId") + .HasColumnType("int") + .HasColumnName("giveawayid"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_giveawayuser"); + + b.HasIndex("GiveawayId", "UserId") + .IsUnique() + .HasDatabaseName("ix_giveawayuser_giveawayid_userid"); + + b.ToTable("giveawayuser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GroupName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("Number") + .HasColumnType("int") + .HasColumnName("number"); + + b.HasKey("Id") + .HasName("pk_groupname"); + + b.HasIndex("GuildConfigId", "Number") + .IsUnique() + .HasDatabaseName("ix_groupname_guildconfigid_number"); + + b.ToTable("groupname", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GuildConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AutoAssignRoleIds") + .HasColumnType("longtext") + .HasColumnName("autoassignroleids"); + + b.Property("AutoDeleteByeMessagesTimer") + .HasColumnType("int") + .HasColumnName("autodeletebyemessagestimer"); + + b.Property("AutoDeleteGreetMessagesTimer") + .HasColumnType("int") + .HasColumnName("autodeletegreetmessagestimer"); + + b.Property("AutoDeleteSelfAssignedRoleMessages") + .HasColumnType("tinyint(1)") + .HasColumnName("autodeleteselfassignedrolemessages"); + + b.Property("BoostMessage") + .HasColumnType("longtext") + .HasColumnName("boostmessage"); + + b.Property("BoostMessageChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("boostmessagechannelid"); + + b.Property("BoostMessageDeleteAfter") + .HasColumnType("int") + .HasColumnName("boostmessagedeleteafter"); + + b.Property("ByeMessageChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("byemessagechannelid"); + + b.Property("ChannelByeMessageText") + .HasColumnType("longtext") + .HasColumnName("channelbyemessagetext"); + + b.Property("ChannelGreetMessageText") + .HasColumnType("longtext") + .HasColumnName("channelgreetmessagetext"); + + b.Property("CleverbotEnabled") + .HasColumnType("tinyint(1)") + .HasColumnName("cleverbotenabled"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("DeleteMessageOnCommand") + .HasColumnType("tinyint(1)") + .HasColumnName("deletemessageoncommand"); + + b.Property("DeleteStreamOnlineMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("deletestreamonlinemessage"); + + b.Property("DisableGlobalExpressions") + .HasColumnType("tinyint(1)") + .HasColumnName("disableglobalexpressions"); + + b.Property("DmGreetMessageText") + .HasColumnType("longtext") + .HasColumnName("dmgreetmessagetext"); + + b.Property("ExclusiveSelfAssignedRoles") + .HasColumnType("tinyint(1)") + .HasColumnName("exclusiveselfassignedroles"); + + b.Property("FilterInvites") + .HasColumnType("tinyint(1)") + .HasColumnName("filterinvites"); + + b.Property("FilterLinks") + .HasColumnType("tinyint(1)") + .HasColumnName("filterlinks"); + + b.Property("FilterWords") + .HasColumnType("tinyint(1)") + .HasColumnName("filterwords"); + + b.Property("GameVoiceChannel") + .HasColumnType("bigint unsigned") + .HasColumnName("gamevoicechannel"); + + b.Property("GreetMessageChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("greetmessagechannelid"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Locale") + .HasColumnType("longtext") + .HasColumnName("locale"); + + b.Property("MuteRoleName") + .HasColumnType("longtext") + .HasColumnName("muterolename"); + + b.Property("NotifyStreamOffline") + .HasColumnType("tinyint(1)") + .HasColumnName("notifystreamoffline"); + + b.Property("PermissionRole") + .HasColumnType("longtext") + .HasColumnName("permissionrole"); + + b.Property("Prefix") + .HasColumnType("longtext") + .HasColumnName("prefix"); + + b.Property("SendBoostMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("sendboostmessage"); + + b.Property("SendChannelByeMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("sendchannelbyemessage"); + + b.Property("SendChannelGreetMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("sendchannelgreetmessage"); + + b.Property("SendDmGreetMessage") + .HasColumnType("tinyint(1)") + .HasColumnName("senddmgreetmessage"); + + b.Property("StickyRoles") + .HasColumnType("tinyint(1)") + .HasColumnName("stickyroles"); + + b.Property("TimeZoneId") + .HasColumnType("longtext") + .HasColumnName("timezoneid"); + + b.Property("VerboseErrors") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("verboseerrors"); + + b.Property("VerbosePermissions") + .HasColumnType("tinyint(1)") + .HasColumnName("verbosepermissions"); + + b.Property("WarnExpireAction") + .HasColumnType("int") + .HasColumnName("warnexpireaction"); + + b.Property("WarnExpireHours") + .HasColumnType("int") + .HasColumnName("warnexpirehours"); + + b.Property("WarningsInitialized") + .HasColumnType("tinyint(1)") + .HasColumnName("warningsinitialized"); + + b.HasKey("Id") + .HasName("pk_guildconfigs"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_guildconfigs_guildid"); + + b.HasIndex("WarnExpireHours") + .HasDatabaseName("ix_guildconfigs_warnexpirehours"); + + b.ToTable("guildconfigs", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.IgnoredLogItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ItemType") + .HasColumnType("int") + .HasColumnName("itemtype"); + + b.Property("LogItemId") + .HasColumnType("bigint unsigned") + .HasColumnName("logitemid"); + + b.Property("LogSettingId") + .HasColumnType("int") + .HasColumnName("logsettingid"); + + b.HasKey("Id") + .HasName("pk_ignoredlogchannels"); + + b.HasIndex("LogSettingId", "LogItemId", "ItemType") + .IsUnique() + .HasDatabaseName("ix_ignoredlogchannels_logsettingid_logitemid_itemtype"); + + b.ToTable("ignoredlogchannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ImageOnlyChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_imageonlychannels"); + + b.HasIndex("ChannelId") + .IsUnique() + .HasDatabaseName("ix_imageonlychannels_channelid"); + + b.ToTable("imageonlychannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelCreatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelcreatedid"); + + b.Property("ChannelDestroyedId") + .HasColumnType("bigint unsigned") + .HasColumnName("channeldestroyedid"); + + b.Property("ChannelUpdatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelupdatedid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("LogOtherId") + .HasColumnType("bigint unsigned") + .HasColumnName("logotherid"); + + b.Property("LogUserPresenceId") + .HasColumnType("bigint unsigned") + .HasColumnName("loguserpresenceid"); + + b.Property("LogVoicePresenceId") + .HasColumnType("bigint unsigned") + .HasColumnName("logvoicepresenceid"); + + b.Property("LogVoicePresenceTTSId") + .HasColumnType("bigint unsigned") + .HasColumnName("logvoicepresencettsid"); + + b.Property("LogWarnsId") + .HasColumnType("bigint unsigned") + .HasColumnName("logwarnsid"); + + b.Property("MessageDeletedId") + .HasColumnType("bigint unsigned") + .HasColumnName("messagedeletedid"); + + b.Property("MessageUpdatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("messageupdatedid"); + + b.Property("ThreadCreatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("threadcreatedid"); + + b.Property("ThreadDeletedId") + .HasColumnType("bigint unsigned") + .HasColumnName("threaddeletedid"); + + b.Property("UserBannedId") + .HasColumnType("bigint unsigned") + .HasColumnName("userbannedid"); + + b.Property("UserJoinedId") + .HasColumnType("bigint unsigned") + .HasColumnName("userjoinedid"); + + b.Property("UserLeftId") + .HasColumnType("bigint unsigned") + .HasColumnName("userleftid"); + + b.Property("UserMutedId") + .HasColumnType("bigint unsigned") + .HasColumnName("usermutedid"); + + b.Property("UserUnbannedId") + .HasColumnType("bigint unsigned") + .HasColumnName("userunbannedid"); + + b.Property("UserUpdatedId") + .HasColumnType("bigint unsigned") + .HasColumnName("userupdatedid"); + + b.HasKey("Id") + .HasName("pk_logsettings"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_logsettings_guildid"); + + b.ToTable("logsettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlayerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AutoDisconnect") + .HasColumnType("tinyint(1)") + .HasColumnName("autodisconnect"); + + b.Property("AutoPlay") + .HasColumnType("tinyint(1)") + .HasColumnName("autoplay"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("MusicChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("musicchannelid"); + + b.Property("PlayerRepeat") + .HasColumnType("int") + .HasColumnName("playerrepeat"); + + b.Property("QualityPreset") + .HasColumnType("int") + .HasColumnName("qualitypreset"); + + b.Property("Volume") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(100) + .HasColumnName("volume"); + + b.HasKey("Id") + .HasName("pk_musicplayersettings"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_musicplayersettings_guildid"); + + b.ToTable("musicplayersettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlaylist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Author") + .HasColumnType("longtext") + .HasColumnName("author"); + + b.Property("AuthorId") + .HasColumnType("bigint unsigned") + .HasColumnName("authorid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_musicplaylists"); + + b.ToTable("musicplaylists", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MutedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_muteduserid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_muteduserid_guildconfigid"); + + b.ToTable("muteduserid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AllowTarget") + .HasColumnType("tinyint(1)") + .HasColumnName("allowtarget"); + + b.Property("AutoDeleteTrigger") + .HasColumnType("tinyint(1)") + .HasColumnName("autodeletetrigger"); + + b.Property("ContainsAnywhere") + .HasColumnType("tinyint(1)") + .HasColumnName("containsanywhere"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("DmResponse") + .HasColumnType("tinyint(1)") + .HasColumnName("dmresponse"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Reactions") + .HasColumnType("longtext") + .HasColumnName("reactions"); + + b.Property("Response") + .HasColumnType("longtext") + .HasColumnName("response"); + + b.Property("Trigger") + .HasColumnType("longtext") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("pk_expressions"); + + b.ToTable("expressions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PatronQuota", b => + { + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("FeatureType") + .HasColumnType("int") + .HasColumnName("featuretype"); + + b.Property("Feature") + .HasColumnType("varchar(255)") + .HasColumnName("feature"); + + b.Property("DailyCount") + .HasColumnType("int unsigned") + .HasColumnName("dailycount"); + + b.Property("HourlyCount") + .HasColumnType("int unsigned") + .HasColumnName("hourlycount"); + + b.Property("MonthlyCount") + .HasColumnType("int unsigned") + .HasColumnName("monthlycount"); + + b.HasKey("UserId", "FeatureType", "Feature") + .HasName("pk_patronquotas"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_patronquotas_userid"); + + b.ToTable("patronquotas", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PatronUser", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UserId")); + + b.Property("AmountCents") + .HasColumnType("int") + .HasColumnName("amountcents"); + + b.Property("LastCharge") + .HasColumnType("datetime(6)") + .HasColumnName("lastcharge"); + + b.Property("UniquePlatformUserId") + .HasColumnType("varchar(255)") + .HasColumnName("uniqueplatformuserid"); + + b.Property("ValidThru") + .HasColumnType("datetime(6)") + .HasColumnName("validthru"); + + b.HasKey("UserId") + .HasName("pk_patrons"); + + b.HasIndex("UniquePlatformUserId") + .IsUnique() + .HasDatabaseName("ix_patrons_uniqueplatformuserid"); + + b.ToTable("patrons", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Permissionv2", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("int") + .HasColumnName("index"); + + b.Property("IsCustomCommand") + .HasColumnType("tinyint(1)") + .HasColumnName("iscustomcommand"); + + b.Property("PrimaryTarget") + .HasColumnType("int") + .HasColumnName("primarytarget"); + + b.Property("PrimaryTargetId") + .HasColumnType("bigint unsigned") + .HasColumnName("primarytargetid"); + + b.Property("SecondaryTarget") + .HasColumnType("int") + .HasColumnName("secondarytarget"); + + b.Property("SecondaryTargetName") + .HasColumnType("longtext") + .HasColumnName("secondarytargetname"); + + b.Property("State") + .HasColumnType("tinyint(1)") + .HasColumnName("state"); + + b.HasKey("Id") + .HasName("pk_permissions"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_permissions_guildconfigid"); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlantedCurrency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("MessageId") + .HasColumnType("bigint unsigned") + .HasColumnName("messageid"); + + b.Property("Password") + .HasColumnType("longtext") + .HasColumnName("password"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_plantedcurrency"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_plantedcurrency_channelid"); + + b.HasIndex("MessageId") + .IsUnique() + .HasDatabaseName("ix_plantedcurrency_messageid"); + + b.ToTable("plantedcurrency", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlaylistSong", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("MusicPlaylistId") + .HasColumnType("int") + .HasColumnName("musicplaylistid"); + + b.Property("Provider") + .HasColumnType("longtext") + .HasColumnName("provider"); + + b.Property("ProviderType") + .HasColumnType("int") + .HasColumnName("providertype"); + + b.Property("Query") + .HasColumnType("longtext") + .HasColumnName("query"); + + b.Property("Title") + .HasColumnType("longtext") + .HasColumnName("title"); + + b.Property("Uri") + .HasColumnType("longtext") + .HasColumnName("uri"); + + b.HasKey("Id") + .HasName("pk_playlistsong"); + + b.HasIndex("MusicPlaylistId") + .HasDatabaseName("ix_playlistsong_musicplaylistid"); + + b.ToTable("playlistsong", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Quote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("bigint unsigned") + .HasColumnName("authorid"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("authorname"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Keyword") + .IsRequired() + .HasColumnType("varchar(255)") + .HasColumnName("keyword"); + + b.Property("Text") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_quotes"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_quotes_guildid"); + + b.HasIndex("Keyword") + .HasDatabaseName("ix_quotes_keyword"); + + b.ToTable("quotes", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ReactionRoleV2", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Emote") + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("emote"); + + b.Property("Group") + .HasColumnType("int") + .HasColumnName("group"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("LevelReq") + .HasColumnType("int") + .HasColumnName("levelreq"); + + b.Property("MessageId") + .HasColumnType("bigint unsigned") + .HasColumnName("messageid"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_reactionroles"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_reactionroles_guildid"); + + b.HasIndex("MessageId", "Emote") + .IsUnique() + .HasDatabaseName("ix_reactionroles_messageid_emote"); + + b.ToTable("reactionroles", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Reminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("IsPrivate") + .HasColumnType("tinyint(1)") + .HasColumnName("isprivate"); + + b.Property("Message") + .HasColumnType("longtext") + .HasColumnName("message"); + + b.Property("ServerId") + .HasColumnType("bigint unsigned") + .HasColumnName("serverid"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("When") + .HasColumnType("datetime(6)") + .HasColumnName("when"); + + b.HasKey("Id") + .HasName("pk_reminders"); + + b.HasIndex("When") + .HasDatabaseName("ix_reminders_when"); + + b.ToTable("reminders", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Repeater", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Interval") + .HasColumnType("time(6)") + .HasColumnName("interval"); + + b.Property("LastMessageId") + .HasColumnType("bigint unsigned") + .HasColumnName("lastmessageid"); + + b.Property("Message") + .HasColumnType("longtext") + .HasColumnName("message"); + + b.Property("NoRedundant") + .HasColumnType("tinyint(1)") + .HasColumnName("noredundant"); + + b.Property("StartTimeOfDay") + .HasColumnType("time(6)") + .HasColumnName("starttimeofday"); + + b.HasKey("Id") + .HasName("pk_repeaters"); + + b.ToTable("repeaters", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.RewardedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AmountRewardedThisMonth") + .HasColumnType("bigint") + .HasColumnName("amountrewardedthismonth"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("LastReward") + .HasColumnType("datetime(6)") + .HasColumnName("lastreward"); + + b.Property("PlatformUserId") + .HasColumnType("varchar(255)") + .HasColumnName("platformuserid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_rewardedusers"); + + b.HasIndex("PlatformUserId") + .IsUnique() + .HasDatabaseName("ix_rewardedusers_platformuserid"); + + b.ToTable("rewardedusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.RotatingPlayingStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Status") + .HasColumnType("longtext") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_rotatingstatus"); + + b.ToTable("rotatingstatus", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SelfAssignedRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Group") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("group"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("LevelRequirement") + .HasColumnType("int") + .HasColumnName("levelrequirement"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_selfassignableroles"); + + b.HasIndex("GuildId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_selfassignableroles_guildid_roleid"); + + b.ToTable("selfassignableroles", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("bigint unsigned") + .HasColumnName("authorid"); + + b.Property("Command") + .HasColumnType("longtext") + .HasColumnName("command"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("int") + .HasColumnName("index"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("Price") + .HasColumnType("int") + .HasColumnName("price"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("RoleName") + .HasColumnType("longtext") + .HasColumnName("rolename"); + + b.Property("RoleRequirement") + .HasColumnType("bigint unsigned") + .HasColumnName("rolerequirement"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_shopentry"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_shopentry_guildconfigid"); + + b.ToTable("shopentry", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ShopEntryId") + .HasColumnType("int") + .HasColumnName("shopentryid"); + + b.Property("Text") + .HasColumnType("longtext") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_shopentryitem"); + + b.HasIndex("ShopEntryId") + .HasDatabaseName("ix_shopentryitem_shopentryid"); + + b.ToTable("shopentryitem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_slowmodeignoredrole"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_slowmodeignoredrole_guildconfigid"); + + b.ToTable("slowmodeignoredrole", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_slowmodeignoreduser"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_slowmodeignoreduser_guildconfigid"); + + b.ToTable("slowmodeignoreduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StickyRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("RoleIds") + .HasColumnType("longtext") + .HasColumnName("roleids"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_stickyroles"); + + b.HasIndex("GuildId", "UserId") + .IsUnique() + .HasDatabaseName("ix_stickyroles_guildid_userid"); + + b.ToTable("stickyroles", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamOnlineMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("MessageId") + .HasColumnType("bigint unsigned") + .HasColumnName("messageid"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("int") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_streamonlinemessages"); + + b.ToTable("streamonlinemessages", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleBlacklistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("int") + .HasColumnName("streamrolesettingsid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("longtext") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_streamroleblacklisteduser"); + + b.HasIndex("StreamRoleSettingsId") + .HasDatabaseName("ix_streamroleblacklisteduser_streamrolesettingsid"); + + b.ToTable("streamroleblacklisteduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AddRoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("addroleid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)") + .HasColumnName("enabled"); + + b.Property("FromRoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("fromroleid"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Keyword") + .HasColumnType("longtext") + .HasColumnName("keyword"); + + b.HasKey("Id") + .HasName("pk_streamrolesettings"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_streamrolesettings_guildconfigid"); + + b.ToTable("streamrolesettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleWhitelistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("int") + .HasColumnName("streamrolesettingsid"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("longtext") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_streamrolewhitelisteduser"); + + b.HasIndex("StreamRoleSettingsId") + .HasDatabaseName("ix_streamrolewhitelisteduser_streamrolesettingsid"); + + b.ToTable("streamrolewhitelisteduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.TodoModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ArchiveId") + .HasColumnType("int") + .HasColumnName("archiveid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("IsDone") + .HasColumnType("tinyint(1)") + .HasColumnName("isdone"); + + b.Property("Todo") + .HasColumnType("longtext") + .HasColumnName("todo"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_todos"); + + b.HasIndex("ArchiveId") + .HasDatabaseName("ix_todos_archiveid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_todos_userid"); + + b.ToTable("todos", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnbanTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("UnbanAt") + .HasColumnType("datetime(6)") + .HasColumnName("unbanat"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unbantimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unbantimer_guildconfigid"); + + b.ToTable("unbantimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnmuteTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("UnmuteAt") + .HasColumnType("datetime(6)") + .HasColumnName("unmuteat"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unmutetimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unmutetimer_guildconfigid"); + + b.ToTable("unmutetimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnroleTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("UnbanAt") + .HasColumnType("datetime(6)") + .HasColumnName("unbanat"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unroletimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unroletimer_guildconfigid"); + + b.ToTable("unroletimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AwardedXp") + .HasColumnType("bigint") + .HasColumnName("awardedxp"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("NotifyOnLevelUp") + .HasColumnType("int") + .HasColumnName("notifyonlevelup"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Xp") + .HasColumnType("bigint") + .HasColumnName("xp"); + + b.HasKey("Id") + .HasName("pk_userxpstats"); + + b.HasIndex("AwardedXp") + .HasDatabaseName("ix_userxpstats_awardedxp"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_userxpstats_guildid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_userxpstats_userid"); + + b.HasIndex("Xp") + .HasDatabaseName("ix_userxpstats_xp"); + + b.HasIndex("UserId", "GuildId") + .IsUnique() + .HasDatabaseName("ix_userxpstats_userid_guildid"); + + b.ToTable("userxpstats", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.VcRoleInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("VoiceChannelId") + .HasColumnType("bigint unsigned") + .HasColumnName("voicechannelid"); + + b.HasKey("Id") + .HasName("pk_vcroleinfo"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_vcroleinfo_guildconfigid"); + + b.ToTable("vcroleinfo", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AffinityId") + .HasColumnType("int") + .HasColumnName("affinityid"); + + b.Property("ClaimerId") + .HasColumnType("int") + .HasColumnName("claimerid"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Price") + .HasColumnType("bigint") + .HasColumnName("price"); + + b.Property("WaifuId") + .HasColumnType("int") + .HasColumnName("waifuid"); + + b.HasKey("Id") + .HasName("pk_waifuinfo"); + + b.HasIndex("AffinityId") + .HasDatabaseName("ix_waifuinfo_affinityid"); + + b.HasIndex("ClaimerId") + .HasDatabaseName("ix_waifuinfo_claimerid"); + + b.HasIndex("Price") + .HasDatabaseName("ix_waifuinfo_price"); + + b.HasIndex("WaifuId") + .IsUnique() + .HasDatabaseName("ix_waifuinfo_waifuid"); + + b.ToTable("waifuinfo", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("ItemEmoji") + .HasColumnType("longtext") + .HasColumnName("itememoji"); + + b.Property("Name") + .HasColumnType("longtext") + .HasColumnName("name"); + + b.Property("WaifuInfoId") + .HasColumnType("int") + .HasColumnName("waifuinfoid"); + + b.HasKey("Id") + .HasName("pk_waifuitem"); + + b.HasIndex("WaifuInfoId") + .HasDatabaseName("ix_waifuitem_waifuinfoid"); + + b.ToTable("waifuitem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("NewId") + .HasColumnType("int") + .HasColumnName("newid"); + + b.Property("OldId") + .HasColumnType("int") + .HasColumnName("oldid"); + + b.Property("UpdateType") + .HasColumnType("int") + .HasColumnName("updatetype"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_waifuupdates"); + + b.HasIndex("NewId") + .HasDatabaseName("ix_waifuupdates_newid"); + + b.HasIndex("OldId") + .HasDatabaseName("ix_waifuupdates_oldid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_waifuupdates_userid"); + + b.ToTable("waifuupdates", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Forgiven") + .HasColumnType("tinyint(1)") + .HasColumnName("forgiven"); + + b.Property("ForgivenBy") + .HasColumnType("longtext") + .HasColumnName("forgivenby"); + + b.Property("GuildId") + .HasColumnType("bigint unsigned") + .HasColumnName("guildid"); + + b.Property("Moderator") + .HasColumnType("longtext") + .HasColumnName("moderator"); + + b.Property("Reason") + .HasColumnType("longtext") + .HasColumnName("reason"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("weight"); + + b.HasKey("Id") + .HasName("pk_warnings"); + + b.HasIndex("DateAdded") + .HasDatabaseName("ix_warnings_dateadded"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_warnings_guildid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_warnings_userid"); + + b.ToTable("warnings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WarningPunishment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("int") + .HasColumnName("count"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("Punishment") + .HasColumnType("int") + .HasColumnName("punishment"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("Time") + .HasColumnType("int") + .HasColumnName("time"); + + b.HasKey("Id") + .HasName("pk_warningpunishment"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_warningpunishment_guildconfigid"); + + b.ToTable("warningpunishment", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpCurrencyReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("int") + .HasColumnName("amount"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Level") + .HasColumnType("int") + .HasColumnName("level"); + + b.Property("XpSettingsId") + .HasColumnType("int") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_xpcurrencyreward"); + + b.HasIndex("XpSettingsId") + .HasDatabaseName("ix_xpcurrencyreward_xpsettingsid"); + + b.ToTable("xpcurrencyreward", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("Level") + .HasColumnType("int") + .HasColumnName("level"); + + b.Property("Remove") + .HasColumnType("tinyint(1)") + .HasColumnName("remove"); + + b.Property("RoleId") + .HasColumnType("bigint unsigned") + .HasColumnName("roleid"); + + b.Property("XpSettingsId") + .HasColumnType("int") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_xprolereward"); + + b.HasIndex("XpSettingsId", "Level") + .IsUnique() + .HasDatabaseName("ix_xprolereward_xpsettingsid_level"); + + b.ToTable("xprolereward", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("int") + .HasColumnName("guildconfigid"); + + b.Property("ServerExcluded") + .HasColumnType("tinyint(1)") + .HasColumnName("serverexcluded"); + + b.HasKey("Id") + .HasName("pk_xpsettings"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_xpsettings_guildconfigid"); + + b.ToTable("xpsettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpShopOwnedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("datetime(6)") + .HasColumnName("dateadded"); + + b.Property("IsUsing") + .HasColumnType("tinyint(1)") + .HasColumnName("isusing"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("varchar(255)") + .HasColumnName("itemkey"); + + b.Property("ItemType") + .HasColumnType("int") + .HasColumnName("itemtype"); + + b.Property("UserId") + .HasColumnType("bigint unsigned") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_xpshopowneditem"); + + b.HasIndex("UserId", "ItemType", "ItemKey") + .IsUnique() + .HasDatabaseName("ix_xpshopowneditem_userid_itemtype_itemkey"); + + b.ToTable("xpshopowneditem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiAltSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiAltSetting") + .HasForeignKey("EllieBot.Db.Models.AntiAltSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antialtsetting_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiRaidSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiRaidSetting") + .HasForeignKey("EllieBot.Db.Models.AntiRaidSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antiraidsetting_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamIgnore", b => + { + b.HasOne("EllieBot.Db.Models.AntiSpamSetting", null) + .WithMany("IgnoredChannels") + .HasForeignKey("AntiSpamSettingId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_antispamignore_antispamsetting_antispamsettingid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiSpamSetting") + .HasForeignKey("EllieBot.Db.Models.AntiSpamSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antispamsetting_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateUser", b => + { + b.HasOne("EllieBot.Db.Models.AutoTranslateChannel", "Channel") + .WithMany("Users") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_autotranslateusers_autotranslatechannels_channelid"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Applicants") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubapplicants_clubs_clubid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubapplicants_discorduser_userid"); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Bans") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubbans_clubs_clubid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubbans_discorduser_userid"); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Owner") + .WithOne() + .HasForeignKey("EllieBot.Db.Models.ClubInfo", "OwnerId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_clubs_discorduser_ownerid"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandAlias", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("CommandAliases") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_commandalias_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandCooldown", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("CommandCooldowns") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_commandcooldown_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DelMsgOnCmdChannel", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("DelMsgOnCmdChannels") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_delmsgoncmdchannel_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Members") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction) + .HasConstraintName("fk_discorduser_clubs_clubid"); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("ExclusionList") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_excludeditem_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("FeedSubs") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_feedsub_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterInvitesChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_filterchannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterLinksChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterLinksChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_filterlinkschannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterWordsChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterWordsChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_filterwordschannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilteredWord", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilteredWords") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_filteredword_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FollowedStreams") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_followedstream_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GCChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("GenerateCurrencyChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_gcchannelid_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayUser", b => + { + b.HasOne("EllieBot.Db.Models.GiveawayModel", null) + .WithMany("Participants") + .HasForeignKey("GiveawayId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_giveawayuser_giveawaymodel_giveawayid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GroupName", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("SelfAssignableRoleGroupNames") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_groupname_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.IgnoredLogItem", b => + { + b.HasOne("EllieBot.Db.Models.LogSetting", "LogSetting") + .WithMany("LogIgnores") + .HasForeignKey("LogSettingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ignoredlogchannels_logsettings_logsettingid"); + + b.Navigation("LogSetting"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MutedUserId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("MutedUsers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_muteduserid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Permissionv2", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("Permissions") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_permissions_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlaylistSong", b => + { + b.HasOne("EllieBot.Db.Models.MusicPlaylist", null) + .WithMany("Songs") + .HasForeignKey("MusicPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_playlistsong_musicplaylists_musicplaylistid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("ShopEntries") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shopentry_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntryItem", b => + { + b.HasOne("EllieBot.Db.Models.ShopEntry", null) + .WithMany("Items") + .HasForeignKey("ShopEntryId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shopentryitem_shopentry_shopentryid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredRole", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredRoles") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_slowmodeignoredrole_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredUser", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredUsers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_slowmodeignoreduser_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleBlacklistedUser", b => + { + b.HasOne("EllieBot.Db.Models.StreamRoleSettings", "StreamRoleSettings") + .WithMany("Blacklist") + .HasForeignKey("StreamRoleSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_streamroleblacklisteduser_streamrolesettings_streamrolesetti~"); + + b.Navigation("StreamRoleSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithOne("StreamRole") + .HasForeignKey("EllieBot.Db.Models.StreamRoleSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_streamrolesettings_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleWhitelistedUser", b => + { + b.HasOne("EllieBot.Db.Models.StreamRoleSettings", "StreamRoleSettings") + .WithMany("Whitelist") + .HasForeignKey("StreamRoleSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_streamrolewhitelisteduser_streamrolesettings_streamrolesetti~"); + + b.Navigation("StreamRoleSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.TodoModel", b => + { + b.HasOne("EllieBot.Db.Models.ArchivedTodoListModel", null) + .WithMany("Items") + .HasForeignKey("ArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_todos_todosarchive_archiveid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnbanTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnbanTimer") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_unbantimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnmuteTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnmuteTimers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_unmutetimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnroleTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnroleTimer") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_unroletimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.VcRoleInfo", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("VcRoleInfos") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_vcroleinfo_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Affinity") + .WithMany() + .HasForeignKey("AffinityId") + .HasConstraintName("fk_waifuinfo_discorduser_affinityid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Claimer") + .WithMany() + .HasForeignKey("ClaimerId") + .HasConstraintName("fk_waifuinfo_discorduser_claimerid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Waifu") + .WithOne() + .HasForeignKey("EllieBot.Db.Models.WaifuInfo", "WaifuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_waifuinfo_discorduser_waifuid"); + + b.Navigation("Affinity"); + + b.Navigation("Claimer"); + + b.Navigation("Waifu"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuItem", b => + { + b.HasOne("EllieBot.Db.Models.WaifuInfo", "WaifuInfo") + .WithMany("Items") + .HasForeignKey("WaifuInfoId") + .HasConstraintName("fk_waifuitem_waifuinfo_waifuinfoid"); + + b.Navigation("WaifuInfo"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuUpdate", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "New") + .WithMany() + .HasForeignKey("NewId") + .HasConstraintName("fk_waifuupdates_discorduser_newid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Old") + .WithMany() + .HasForeignKey("OldId") + .HasConstraintName("fk_waifuupdates_discorduser_oldid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_waifuupdates_discorduser_userid"); + + b.Navigation("New"); + + b.Navigation("Old"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WarningPunishment", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("WarnPunishments") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_warningpunishment_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpCurrencyReward", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("CurrencyRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xpcurrencyreward_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("RoleRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xprolereward_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithOne("XpSettings") + .HasForeignKey("EllieBot.Db.Models.XpSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xpsettings_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.Navigation("IgnoredChannels"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ArchivedTodoListModel", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateChannel", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Navigation("Applicants"); + + b.Navigation("Bans"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayModel", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GuildConfig", b => + { + b.Navigation("AntiAltSetting"); + + b.Navigation("AntiRaidSetting"); + + b.Navigation("AntiSpamSetting"); + + b.Navigation("CommandAliases"); + + b.Navigation("CommandCooldowns"); + + b.Navigation("DelMsgOnCmdChannels"); + + b.Navigation("FeedSubs"); + + b.Navigation("FilterInvitesChannelIds"); + + b.Navigation("FilterLinksChannelIds"); + + b.Navigation("FilterWordsChannelIds"); + + b.Navigation("FilteredWords"); + + b.Navigation("FollowedStreams"); + + b.Navigation("GenerateCurrencyChannelIds"); + + b.Navigation("MutedUsers"); + + b.Navigation("Permissions"); + + b.Navigation("SelfAssignableRoleGroupNames"); + + b.Navigation("ShopEntries"); + + b.Navigation("SlowmodeIgnoredRoles"); + + b.Navigation("SlowmodeIgnoredUsers"); + + b.Navigation("StreamRole"); + + b.Navigation("UnbanTimer"); + + b.Navigation("UnmuteTimers"); + + b.Navigation("UnroleTimer"); + + b.Navigation("VcRoleInfos"); + + b.Navigation("WarnPunishments"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b => + { + b.Navigation("LogIgnores"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlaylist", b => + { + b.Navigation("Songs"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.Navigation("Blacklist"); + + b.Navigation("Whitelist"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.Navigation("CurrencyRewards"); + + b.Navigation("ExclusionList"); + + b.Navigation("RoleRewards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EllieBot/Migrations/PostgreSql/20220409170719_mysql-init.Designer.cs b/src/EllieBot/Migrations/PostgreSql/20220409170719_mysql-init.Designer.cs new file mode 100644 index 0000000..32cccfb --- /dev/null +++ b/src/EllieBot/Migrations/PostgreSql/20220409170719_mysql-init.Designer.cs @@ -0,0 +1,3565 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using EllieBot.Services.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EllieBot.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlContext))] + [Migration("20220409170719_mysql-init")] + partial class mysqlinit + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.Property("ClubId") + .HasColumnType("integer") + .HasColumnName("clubid"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("userid"); + + b.HasKey("ClubId", "UserId") + .HasName("pk_clubapplicants"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_clubapplicants_userid"); + + b.ToTable("clubapplicants", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.Property("ClubId") + .HasColumnType("integer") + .HasColumnName("clubid"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("userid"); + + b.HasKey("ClubId", "UserId") + .HasName("pk_clubbans"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_clubbans_userid"); + + b.ToTable("clubbans", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ImageUrl") + .HasColumnType("text") + .HasColumnName("imageurl"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("integer") + .HasColumnName("ownerid"); + + b.Property("Xp") + .HasColumnType("integer") + .HasColumnName("xp"); + + b.HasKey("Id") + .HasName("pk_clubs"); + + b.HasAlternateKey("Name") + .HasName("ak_clubs_name"); + + b.HasIndex("OwnerId") + .IsUnique() + .HasDatabaseName("ix_clubs_ownerid"); + + b.ToTable("clubs", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarId") + .HasColumnType("text") + .HasColumnName("avatarid"); + + b.Property("ClubId") + .HasColumnType("integer") + .HasColumnName("clubid"); + + b.Property("CurrencyAmount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("currencyamount"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Discriminator") + .HasColumnType("text") + .HasColumnName("discriminator"); + + b.Property("IsClubAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("isclubadmin"); + + b.Property("LastLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("lastlevelup") + .HasDefaultValueSql("timezone('utc', now())"); + + b.Property("LastXpGain") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("lastxpgain") + .HasDefaultValueSql("timezone('utc', now()) - interval '-1 year'"); + + b.Property("NotifyOnLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("notifyonlevelup"); + + b.Property("TotalXp") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("totalxp"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_discorduser"); + + b.HasAlternateKey("UserId") + .HasName("ak_discorduser_userid"); + + b.HasIndex("ClubId") + .HasDatabaseName("ix_discorduser_clubid"); + + b.HasIndex("CurrencyAmount") + .HasDatabaseName("ix_discorduser_currencyamount"); + + b.HasIndex("TotalXp") + .HasDatabaseName("ix_discorduser_totalxp"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_discorduser_userid"); + + b.ToTable("discorduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_followedstream"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_followedstream_guildconfigid"); + + b.ToTable("followedstream", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiAltSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasColumnName("action"); + + b.Property("ActionDurationMinutes") + .HasColumnType("integer") + .HasColumnName("actiondurationminutes"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("MinAge") + .HasColumnType("interval") + .HasColumnName("minage"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_antialtsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antialtsetting_guildconfigid"); + + b.ToTable("antialtsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiRaidSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasColumnName("action"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("PunishDuration") + .HasColumnType("integer") + .HasColumnName("punishduration"); + + b.Property("Seconds") + .HasColumnType("integer") + .HasColumnName("seconds"); + + b.Property("UserThreshold") + .HasColumnType("integer") + .HasColumnName("userthreshold"); + + b.HasKey("Id") + .HasName("pk_antiraidsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antiraidsetting_guildconfigid"); + + b.ToTable("antiraidsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamIgnore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AntiSpamSettingId") + .HasColumnType("integer") + .HasColumnName("antispamsettingid"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.HasKey("Id") + .HasName("pk_antispamignore"); + + b.HasIndex("AntiSpamSettingId") + .HasDatabaseName("ix_antispamignore_antispamsettingid"); + + b.ToTable("antispamignore", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasColumnName("action"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("MessageThreshold") + .HasColumnType("integer") + .HasColumnName("messagethreshold"); + + b.Property("MuteTime") + .HasColumnType("integer") + .HasColumnName("mutetime"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_antispamsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antispamsetting_guildconfigid"); + + b.ToTable("antispamsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("ChannelName") + .HasColumnType("text") + .HasColumnName("channelname"); + + b.Property("CommandText") + .HasColumnType("text") + .HasColumnName("commandtext"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("GuildName") + .HasColumnType("text") + .HasColumnName("guildname"); + + b.Property("Interval") + .HasColumnType("integer") + .HasColumnName("interval"); + + b.Property("VoiceChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("voicechannelid"); + + b.Property("VoiceChannelName") + .HasColumnType("text") + .HasColumnName("voicechannelname"); + + b.HasKey("Id") + .HasName("pk_autocommands"); + + b.ToTable("autocommands", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoTranslateChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoDelete") + .HasColumnType("boolean") + .HasColumnName("autodelete"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.HasKey("Id") + .HasName("pk_autotranslatechannels"); + + b.HasIndex("ChannelId") + .IsUnique() + .HasDatabaseName("ix_autotranslatechannels_channelid"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_autotranslatechannels_guildid"); + + b.ToTable("autotranslatechannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoTranslateUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Source") + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Target") + .HasColumnType("text") + .HasColumnName("target"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_autotranslateusers"); + + b.HasAlternateKey("ChannelId", "UserId") + .HasName("ak_autotranslateusers_channelid_userid"); + + b.ToTable("autotranslateusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.BanTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_bantemplates"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_bantemplates_guildid"); + + b.ToTable("bantemplates", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.BlacklistEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("ItemId") + .HasColumnType("numeric(20,0)") + .HasColumnName("itemid"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_blacklist"); + + b.ToTable("blacklist", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CommandAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Mapping") + .HasColumnType("text") + .HasColumnName("mapping"); + + b.Property("Trigger") + .HasColumnType("text") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("pk_commandalias"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_commandalias_guildconfigid"); + + b.ToTable("commandalias", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CommandCooldown", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommandName") + .HasColumnType("text") + .HasColumnName("commandname"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Seconds") + .HasColumnType("integer") + .HasColumnName("seconds"); + + b.HasKey("Id") + .HasName("pk_commandcooldown"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_commandcooldown_guildconfigid"); + + b.ToTable("commandcooldown", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CurrencyTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("text") + .HasColumnName("extra"); + + b.Property("Note") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property("OtherId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("otherid") + .HasDefaultValueSql("NULL"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_currencytransactions"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_currencytransactions_userid"); + + b.ToTable("currencytransactions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.DelMsgOnCmdChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("State") + .HasColumnType("boolean") + .HasColumnName("state"); + + b.HasKey("Id") + .HasName("pk_delmsgoncmdchannel"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_delmsgoncmdchannel_guildconfigid"); + + b.ToTable("delmsgoncmdchannel", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.DiscordPermOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Command") + .HasColumnType("text") + .HasColumnName("command"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Perm") + .HasColumnType("numeric(20,0)") + .HasColumnName("perm"); + + b.HasKey("Id") + .HasName("pk_discordpermoverrides"); + + b.HasIndex("GuildId", "Command") + .IsUnique() + .HasDatabaseName("ix_discordpermoverrides_guildid_command"); + + b.ToTable("discordpermoverrides", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ExcludedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("ItemId") + .HasColumnType("numeric(20,0)") + .HasColumnName("itemid"); + + b.Property("ItemType") + .HasColumnType("integer") + .HasColumnName("itemtype"); + + b.Property("XpSettingsId") + .HasColumnType("integer") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_excludeditem"); + + b.HasIndex("XpSettingsId") + .HasDatabaseName("ix_excludeditem_xpsettingsid"); + + b.ToTable("excludeditem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FeedSub", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_feedsub"); + + b.HasAlternateKey("GuildConfigId", "Url") + .HasName("ak_feedsub_guildconfigid_url"); + + b.ToTable("feedsub", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterchannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterchannelid_guildconfigid"); + + b.ToTable("filterchannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilteredWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Word") + .HasColumnType("text") + .HasColumnName("word"); + + b.HasKey("Id") + .HasName("pk_filteredword"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filteredword_guildconfigid"); + + b.ToTable("filteredword", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterLinksChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterlinkschannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterlinkschannelid_guildconfigid"); + + b.ToTable("filterlinkschannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterWordsChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterwordschannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterwordschannelid_guildconfigid"); + + b.ToTable("filterwordschannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GCChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_gcchannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_gcchannelid_guildconfigid"); + + b.ToTable("gcchannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GroupName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.HasKey("Id") + .HasName("pk_groupname"); + + b.HasIndex("GuildConfigId", "Number") + .IsUnique() + .HasDatabaseName("ix_groupname_guildconfigid_number"); + + b.ToTable("groupname", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GuildConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoAssignRoleIds") + .HasColumnType("text") + .HasColumnName("autoassignroleids"); + + b.Property("AutoDeleteByeMessagesTimer") + .HasColumnType("integer") + .HasColumnName("autodeletebyemessagestimer"); + + b.Property("AutoDeleteGreetMessagesTimer") + .HasColumnType("integer") + .HasColumnName("autodeletegreetmessagestimer"); + + b.Property("AutoDeleteSelfAssignedRoleMessages") + .HasColumnType("boolean") + .HasColumnName("autodeleteselfassignedrolemessages"); + + b.Property("BoostMessage") + .HasColumnType("text") + .HasColumnName("boostmessage"); + + b.Property("BoostMessageChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("boostmessagechannelid"); + + b.Property("BoostMessageDeleteAfter") + .HasColumnType("integer") + .HasColumnName("boostmessagedeleteafter"); + + b.Property("ByeMessageChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("byemessagechannelid"); + + b.Property("ChannelByeMessageText") + .HasColumnType("text") + .HasColumnName("channelbyemessagetext"); + + b.Property("ChannelGreetMessageText") + .HasColumnType("text") + .HasColumnName("channelgreetmessagetext"); + + b.Property("CleverbotEnabled") + .HasColumnType("boolean") + .HasColumnName("cleverbotenabled"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("DeleteMessageOnCommand") + .HasColumnType("boolean") + .HasColumnName("deletemessageoncommand"); + + b.Property("DmGreetMessageText") + .HasColumnType("text") + .HasColumnName("dmgreetmessagetext"); + + b.Property("ExclusiveSelfAssignedRoles") + .HasColumnType("boolean") + .HasColumnName("exclusiveselfassignedroles"); + + b.Property("FilterInvites") + .HasColumnType("boolean") + .HasColumnName("filterinvites"); + + b.Property("FilterLinks") + .HasColumnType("boolean") + .HasColumnName("filterlinks"); + + b.Property("FilterWords") + .HasColumnType("boolean") + .HasColumnName("filterwords"); + + b.Property("GameVoiceChannel") + .HasColumnType("numeric(20,0)") + .HasColumnName("gamevoicechannel"); + + b.Property("GreetMessageChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("greetmessagechannelid"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Locale") + .HasColumnType("text") + .HasColumnName("locale"); + + b.Property("MuteRoleName") + .HasColumnType("text") + .HasColumnName("muterolename"); + + b.Property("NotifyStreamOffline") + .HasColumnType("boolean") + .HasColumnName("notifystreamoffline"); + + b.Property("PermissionRole") + .HasColumnType("text") + .HasColumnName("permissionrole"); + + b.Property("Prefix") + .HasColumnType("text") + .HasColumnName("prefix"); + + b.Property("SendBoostMessage") + .HasColumnType("boolean") + .HasColumnName("sendboostmessage"); + + b.Property("SendChannelByeMessage") + .HasColumnType("boolean") + .HasColumnName("sendchannelbyemessage"); + + b.Property("SendChannelGreetMessage") + .HasColumnType("boolean") + .HasColumnName("sendchannelgreetmessage"); + + b.Property("SendDmGreetMessage") + .HasColumnType("boolean") + .HasColumnName("senddmgreetmessage"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("timezoneid"); + + b.Property("VerboseErrors") + .HasColumnType("boolean") + .HasColumnName("verboseerrors"); + + b.Property("VerbosePermissions") + .HasColumnType("boolean") + .HasColumnName("verbosepermissions"); + + b.Property("WarnExpireAction") + .HasColumnType("integer") + .HasColumnName("warnexpireaction"); + + b.Property("WarnExpireHours") + .HasColumnType("integer") + .HasColumnName("warnexpirehours"); + + b.Property("WarningsInitialized") + .HasColumnType("boolean") + .HasColumnName("warningsinitialized"); + + b.HasKey("Id") + .HasName("pk_guildconfigs"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_guildconfigs_guildid"); + + b.HasIndex("WarnExpireHours") + .HasDatabaseName("ix_guildconfigs_warnexpirehours"); + + b.ToTable("guildconfigs", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.IgnoredLogItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("ItemType") + .HasColumnType("integer") + .HasColumnName("itemtype"); + + b.Property("LogItemId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logitemid"); + + b.Property("LogSettingId") + .HasColumnType("integer") + .HasColumnName("logsettingid"); + + b.HasKey("Id") + .HasName("pk_ignoredlogchannels"); + + b.HasIndex("LogSettingId", "LogItemId", "ItemType") + .IsUnique() + .HasDatabaseName("ix_ignoredlogchannels_logsettingid_logitemid_itemtype"); + + b.ToTable("ignoredlogchannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.IgnoredVoicePresenceChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("LogSettingId") + .HasColumnType("integer") + .HasColumnName("logsettingid"); + + b.HasKey("Id") + .HasName("pk_ignoredvoicepresencechannels"); + + b.HasIndex("LogSettingId") + .HasDatabaseName("ix_ignoredvoicepresencechannels_logsettingid"); + + b.ToTable("ignoredvoicepresencechannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ImageOnlyChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.HasKey("Id") + .HasName("pk_imageonlychannels"); + + b.HasIndex("ChannelId") + .IsUnique() + .HasDatabaseName("ix_imageonlychannels_channelid"); + + b.ToTable("imageonlychannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.LogSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelCreatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelcreatedid"); + + b.Property("ChannelDestroyedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channeldestroyedid"); + + b.Property("ChannelUpdatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelupdatedid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("LogOtherId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logotherid"); + + b.Property("LogUserPresenceId") + .HasColumnType("numeric(20,0)") + .HasColumnName("loguserpresenceid"); + + b.Property("LogVoicePresenceId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logvoicepresenceid"); + + b.Property("LogVoicePresenceTTSId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logvoicepresencettsid"); + + b.Property("MessageDeletedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messagedeletedid"); + + b.Property("MessageUpdatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messageupdatedid"); + + b.Property("UserBannedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userbannedid"); + + b.Property("UserJoinedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userjoinedid"); + + b.Property("UserLeftId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userleftid"); + + b.Property("UserMutedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("usermutedid"); + + b.Property("UserUnbannedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userunbannedid"); + + b.Property("UserUpdatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userupdatedid"); + + b.HasKey("Id") + .HasName("pk_logsettings"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_logsettings_guildid"); + + b.ToTable("logsettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MusicPlayerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoDisconnect") + .HasColumnType("boolean") + .HasColumnName("autodisconnect"); + + b.Property("AutoPlay") + .HasColumnType("boolean") + .HasColumnName("autoplay"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("MusicChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("musicchannelid"); + + b.Property("PlayerRepeat") + .HasColumnType("integer") + .HasColumnName("playerrepeat"); + + b.Property("QualityPreset") + .HasColumnType("integer") + .HasColumnName("qualitypreset"); + + b.Property("Volume") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasColumnName("volume"); + + b.HasKey("Id") + .HasName("pk_musicplayersettings"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_musicplayersettings_guildid"); + + b.ToTable("musicplayersettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MusicPlaylist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("AuthorId") + .HasColumnType("numeric(20,0)") + .HasColumnName("authorid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_musicplaylists"); + + b.ToTable("musicplaylists", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MutedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_muteduserid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_muteduserid_guildconfigid"); + + b.ToTable("muteduserid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.EllieExpression", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowTarget") + .HasColumnType("boolean") + .HasColumnName("allowtarget"); + + b.Property("AutoDeleteTrigger") + .HasColumnType("boolean") + .HasColumnName("autodeletetrigger"); + + b.Property("ContainsAnywhere") + .HasColumnType("boolean") + .HasColumnName("containsanywhere"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("DmResponse") + .HasColumnType("boolean") + .HasColumnName("dmresponse"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Reactions") + .HasColumnType("text") + .HasColumnName("reactions"); + + b.Property("Response") + .HasColumnType("text") + .HasColumnName("response"); + + b.Property("Trigger") + .HasColumnType("text") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("pk_expressions"); + + b.ToTable("expressions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.NsfwBlacklistedTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Tag") + .HasColumnType("text") + .HasColumnName("tag"); + + b.HasKey("Id") + .HasName("pk_nsfwblacklistedtags"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_nsfwblacklistedtags_guildid"); + + b.ToTable("nsfwblacklistedtags", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Permissionv2", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("IsCustomCommand") + .HasColumnType("boolean") + .HasColumnName("iscustomcommand"); + + b.Property("PrimaryTarget") + .HasColumnType("integer") + .HasColumnName("primarytarget"); + + b.Property("PrimaryTargetId") + .HasColumnType("numeric(20,0)") + .HasColumnName("primarytargetid"); + + b.Property("SecondaryTarget") + .HasColumnType("integer") + .HasColumnName("secondarytarget"); + + b.Property("SecondaryTargetName") + .HasColumnType("text") + .HasColumnName("secondarytargetname"); + + b.Property("State") + .HasColumnType("boolean") + .HasColumnName("state"); + + b.HasKey("Id") + .HasName("pk_permissions"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_permissions_guildconfigid"); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PlantedCurrency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("MessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messageid"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_plantedcurrency"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_plantedcurrency_channelid"); + + b.HasIndex("MessageId") + .IsUnique() + .HasDatabaseName("ix_plantedcurrency_messageid"); + + b.ToTable("plantedcurrency", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PlaylistSong", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("MusicPlaylistId") + .HasColumnType("integer") + .HasColumnName("musicplaylistid"); + + b.Property("Provider") + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("ProviderType") + .HasColumnType("integer") + .HasColumnName("providertype"); + + b.Property("Query") + .HasColumnType("text") + .HasColumnName("query"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Uri") + .HasColumnType("text") + .HasColumnName("uri"); + + b.HasKey("Id") + .HasName("pk_playlistsong"); + + b.HasIndex("MusicPlaylistId") + .HasDatabaseName("ix_playlistsong_musicplaylistid"); + + b.ToTable("playlistsong", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Poll", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Question") + .HasColumnType("text") + .HasColumnName("question"); + + b.HasKey("Id") + .HasName("pk_poll"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_poll_guildid"); + + b.ToTable("poll", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PollAnswer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("PollId") + .HasColumnType("integer") + .HasColumnName("pollid"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_pollanswer"); + + b.HasIndex("PollId") + .HasDatabaseName("ix_pollanswer_pollid"); + + b.ToTable("pollanswer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PollVote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("PollId") + .HasColumnType("integer") + .HasColumnName("pollid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("VoteIndex") + .HasColumnType("integer") + .HasColumnName("voteindex"); + + b.HasKey("Id") + .HasName("pk_pollvote"); + + b.HasIndex("PollId") + .HasDatabaseName("ix_pollvote_pollid"); + + b.ToTable("pollvote", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Quote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("numeric(20,0)") + .HasColumnName("authorid"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("authorname"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Keyword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("keyword"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_quotes"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_quotes_guildid"); + + b.HasIndex("Keyword") + .HasDatabaseName("ix_quotes_keyword"); + + b.ToTable("quotes", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("EmoteName") + .HasColumnType("text") + .HasColumnName("emotename"); + + b.Property("ReactionRoleMessageId") + .HasColumnType("integer") + .HasColumnName("reactionrolemessageid"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_reactionrole"); + + b.HasIndex("ReactionRoleMessageId") + .HasDatabaseName("ix_reactionrole_reactionrolemessageid"); + + b.ToTable("reactionrole", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRoleMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Exclusive") + .HasColumnType("boolean") + .HasColumnName("exclusive"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("MessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messageid"); + + b.HasKey("Id") + .HasName("pk_reactionrolemessage"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_reactionrolemessage_guildconfigid"); + + b.ToTable("reactionrolemessage", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Reminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("isprivate"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("ServerId") + .HasColumnType("numeric(20,0)") + .HasColumnName("serverid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("When") + .HasColumnType("timestamp with time zone") + .HasColumnName("when"); + + b.HasKey("Id") + .HasName("pk_reminders"); + + b.HasIndex("When") + .HasDatabaseName("ix_reminders_when"); + + b.ToTable("reminders", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Repeater", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Interval") + .HasColumnType("interval") + .HasColumnName("interval"); + + b.Property("LastMessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("lastmessageid"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("NoRedundant") + .HasColumnType("boolean") + .HasColumnName("noredundant"); + + b.Property("StartTimeOfDay") + .HasColumnType("interval") + .HasColumnName("starttimeofday"); + + b.HasKey("Id") + .HasName("pk_repeaters"); + + b.ToTable("repeaters", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.RewardedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountRewardedThisMonth") + .HasColumnType("integer") + .HasColumnName("amountrewardedthismonth"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("LastReward") + .HasColumnType("timestamp with time zone") + .HasColumnName("lastreward"); + + b.Property("PatreonUserId") + .HasColumnType("text") + .HasColumnName("patreonuserid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_rewardedusers"); + + b.HasIndex("PatreonUserId") + .IsUnique() + .HasDatabaseName("ix_rewardedusers_patreonuserid"); + + b.ToTable("rewardedusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.RotatingPlayingStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Status") + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_rotatingstatus"); + + b.ToTable("rotatingstatus", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SelfAssignedRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Group") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("group"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("LevelRequirement") + .HasColumnType("integer") + .HasColumnName("levelrequirement"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_selfassignableroles"); + + b.HasIndex("GuildId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_selfassignableroles_guildid_roleid"); + + b.ToTable("selfassignableroles", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("numeric(20,0)") + .HasColumnName("authorid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Price") + .HasColumnType("integer") + .HasColumnName("price"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("RoleName") + .HasColumnType("text") + .HasColumnName("rolename"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_shopentry"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_shopentry_guildconfigid"); + + b.ToTable("shopentry", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("ShopEntryId") + .HasColumnType("integer") + .HasColumnName("shopentryid"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_shopentryitem"); + + b.HasIndex("ShopEntryId") + .HasDatabaseName("ix_shopentryitem_shopentryid"); + + b.ToTable("shopentryitem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SlowmodeIgnoredRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_slowmodeignoredrole"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_slowmodeignoredrole_guildconfigid"); + + b.ToTable("slowmodeignoredrole", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SlowmodeIgnoredUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_slowmodeignoreduser"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_slowmodeignoreduser_guildconfigid"); + + b.ToTable("slowmodeignoreduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleBlacklistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("integer") + .HasColumnName("streamrolesettingsid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_streamroleblacklisteduser"); + + b.HasIndex("StreamRoleSettingsId") + .HasDatabaseName("ix_streamroleblacklisteduser_streamrolesettingsid"); + + b.ToTable("streamroleblacklisteduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddRoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("addroleid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("FromRoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("fromroleid"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Keyword") + .HasColumnType("text") + .HasColumnName("keyword"); + + b.HasKey("Id") + .HasName("pk_streamrolesettings"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_streamrolesettings_guildconfigid"); + + b.ToTable("streamrolesettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleWhitelistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("integer") + .HasColumnName("streamrolesettingsid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_streamrolewhitelisteduser"); + + b.HasIndex("StreamRoleSettingsId") + .HasDatabaseName("ix_streamrolewhitelisteduser_streamrolesettingsid"); + + b.ToTable("streamrolewhitelisteduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnbanTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("UnbanAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("unbanat"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unbantimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unbantimer_guildconfigid"); + + b.ToTable("unbantimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnmuteTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("UnmuteAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("unmuteat"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unmutetimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unmutetimer_guildconfigid"); + + b.ToTable("unmutetimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnroleTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("UnbanAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("unbanat"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unroletimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unroletimer_guildconfigid"); + + b.ToTable("unroletimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UserXpStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwardedXp") + .HasColumnType("integer") + .HasColumnName("awardedxp"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("LastLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("lastlevelup") + .HasDefaultValueSql("timezone('utc', now())"); + + b.Property("NotifyOnLevelUp") + .HasColumnType("integer") + .HasColumnName("notifyonlevelup"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Xp") + .HasColumnType("integer") + .HasColumnName("xp"); + + b.HasKey("Id") + .HasName("pk_userxpstats"); + + b.HasIndex("AwardedXp") + .HasDatabaseName("ix_userxpstats_awardedxp"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_userxpstats_guildid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_userxpstats_userid"); + + b.HasIndex("Xp") + .HasDatabaseName("ix_userxpstats_xp"); + + b.HasIndex("UserId", "GuildId") + .IsUnique() + .HasDatabaseName("ix_userxpstats_userid_guildid"); + + b.ToTable("userxpstats", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.VcRoleInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("VoiceChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("voicechannelid"); + + b.HasKey("Id") + .HasName("pk_vcroleinfo"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_vcroleinfo_guildconfigid"); + + b.ToTable("vcroleinfo", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffinityId") + .HasColumnType("integer") + .HasColumnName("affinityid"); + + b.Property("ClaimerId") + .HasColumnType("integer") + .HasColumnName("claimerid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Price") + .HasColumnType("bigint") + .HasColumnName("price"); + + b.Property("WaifuId") + .HasColumnType("integer") + .HasColumnName("waifuid"); + + b.HasKey("Id") + .HasName("pk_waifuinfo"); + + b.HasIndex("AffinityId") + .HasDatabaseName("ix_waifuinfo_affinityid"); + + b.HasIndex("ClaimerId") + .HasDatabaseName("ix_waifuinfo_claimerid"); + + b.HasIndex("Price") + .HasDatabaseName("ix_waifuinfo_price"); + + b.HasIndex("WaifuId") + .IsUnique() + .HasDatabaseName("ix_waifuinfo_waifuid"); + + b.ToTable("waifuinfo", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("ItemEmoji") + .HasColumnType("text") + .HasColumnName("itememoji"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("WaifuInfoId") + .HasColumnType("integer") + .HasColumnName("waifuinfoid"); + + b.HasKey("Id") + .HasName("pk_waifuitem"); + + b.HasIndex("WaifuInfoId") + .HasDatabaseName("ix_waifuitem_waifuinfoid"); + + b.ToTable("waifuitem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("NewId") + .HasColumnType("integer") + .HasColumnName("newid"); + + b.Property("OldId") + .HasColumnType("integer") + .HasColumnName("oldid"); + + b.Property("UpdateType") + .HasColumnType("integer") + .HasColumnName("updatetype"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_waifuupdates"); + + b.HasIndex("NewId") + .HasDatabaseName("ix_waifuupdates_newid"); + + b.HasIndex("OldId") + .HasDatabaseName("ix_waifuupdates_oldid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_waifuupdates_userid"); + + b.ToTable("waifuupdates", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Forgiven") + .HasColumnType("boolean") + .HasColumnName("forgiven"); + + b.Property("ForgivenBy") + .HasColumnType("text") + .HasColumnName("forgivenby"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Moderator") + .HasColumnType("text") + .HasColumnName("moderator"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("weight"); + + b.HasKey("Id") + .HasName("pk_warnings"); + + b.HasIndex("DateAdded") + .HasDatabaseName("ix_warnings_dateadded"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_warnings_guildid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_warnings_userid"); + + b.ToTable("warnings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WarningPunishment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Punishment") + .HasColumnType("integer") + .HasColumnName("punishment"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("Time") + .HasColumnType("integer") + .HasColumnName("time"); + + b.HasKey("Id") + .HasName("pk_warningpunishment"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_warningpunishment_guildconfigid"); + + b.ToTable("warningpunishment", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpCurrencyReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("XpSettingsId") + .HasColumnType("integer") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_xpcurrencyreward"); + + b.HasIndex("XpSettingsId") + .HasDatabaseName("ix_xpcurrencyreward_xpsettingsid"); + + b.ToTable("xpcurrencyreward", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpRoleReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("Remove") + .HasColumnType("boolean") + .HasColumnName("remove"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("XpSettingsId") + .HasColumnType("integer") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_xprolereward"); + + b.HasIndex("XpSettingsId", "Level") + .IsUnique() + .HasDatabaseName("ix_xprolereward_xpsettingsid_level"); + + b.ToTable("xprolereward", (string)null); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("ServerExcluded") + .HasColumnType("boolean") + .HasColumnName("serverexcluded"); + + b.HasKey("Id") + .HasName("pk_xpsettings"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_xpsettings_guildconfigid"); + + b.ToTable("xpsettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Applicants") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubapplicants_clubs_clubid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubapplicants_discorduser_userid"); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Bans") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubbans_clubs_clubid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubbans_discorduser_userid"); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Owner") + .WithOne() + .HasForeignKey("EllieBot.Db.Models.ClubInfo", "OwnerId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_clubs_discorduser_ownerid"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Members") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction) + .HasConstraintName("fk_discorduser_clubs_clubid"); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FollowedStreams") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_followedstream_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiAltSetting", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithOne("AntiAltSetting") + .HasForeignKey("EllieBot.Services.Database.Models.AntiAltSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antialtsetting_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiRaidSetting", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithOne("AntiRaidSetting") + .HasForeignKey("EllieBot.Services.Database.Models.AntiRaidSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antiraidsetting_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamIgnore", b => + { + b.HasOne("EllieBot.Services.Database.Models.AntiSpamSetting", null) + .WithMany("IgnoredChannels") + .HasForeignKey("AntiSpamSettingId") + .HasConstraintName("fk_antispamignore_antispamsetting_antispamsettingid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamSetting", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithOne("AntiSpamSetting") + .HasForeignKey("EllieBot.Services.Database.Models.AntiSpamSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antispamsetting_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoTranslateUser", b => + { + b.HasOne("EllieBot.Services.Database.Models.AutoTranslateChannel", "Channel") + .WithMany("Users") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_autotranslateusers_autotranslatechannels_channelid"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CommandAlias", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("CommandAliases") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_commandalias_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.CommandCooldown", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("CommandCooldowns") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_commandcooldown_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.DelMsgOnCmdChannel", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("DelMsgOnCmdChannels") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_delmsgoncmdchannel_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ExcludedItem", b => + { + b.HasOne("EllieBot.Services.Database.Models.XpSettings", null) + .WithMany("ExclusionList") + .HasForeignKey("XpSettingsId") + .HasConstraintName("fk_excludeditem_xpsettings_xpsettingsid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FeedSub", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithMany("FeedSubs") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_feedsub_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterChannelId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FilterInvitesChannelIds") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_filterchannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilteredWord", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FilteredWords") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_filteredword_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterLinksChannelId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FilterLinksChannelIds") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_filterlinkschannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.FilterWordsChannelId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("FilterWordsChannelIds") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_filterwordschannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GCChannelId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithMany("GenerateCurrencyChannelIds") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_gcchannelid_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GroupName", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithMany("SelfAssignableRoleGroupNames") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_groupname_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.IgnoredLogItem", b => + { + b.HasOne("EllieBot.Services.Database.Models.LogSetting", "LogSetting") + .WithMany("LogIgnores") + .HasForeignKey("LogSettingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ignoredlogchannels_logsettings_logsettingid"); + + b.Navigation("LogSetting"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.IgnoredVoicePresenceChannel", b => + { + b.HasOne("EllieBot.Services.Database.Models.LogSetting", "LogSetting") + .WithMany() + .HasForeignKey("LogSettingId") + .HasConstraintName("fk_ignoredvoicepresencechannels_logsettings_logsettingid"); + + b.Navigation("LogSetting"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MutedUserId", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("MutedUsers") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_muteduserid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Permissionv2", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("Permissions") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_permissions_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PlaylistSong", b => + { + b.HasOne("EllieBot.Services.Database.Models.MusicPlaylist", null) + .WithMany("Songs") + .HasForeignKey("MusicPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_playlistsong_musicplaylists_musicplaylistid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PollAnswer", b => + { + b.HasOne("EllieBot.Services.Database.Models.Poll", null) + .WithMany("Answers") + .HasForeignKey("PollId") + .HasConstraintName("fk_pollanswer_poll_pollid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.PollVote", b => + { + b.HasOne("EllieBot.Services.Database.Models.Poll", null) + .WithMany("Votes") + .HasForeignKey("PollId") + .HasConstraintName("fk_pollvote_poll_pollid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRole", b => + { + b.HasOne("EllieBot.Services.Database.Models.ReactionRoleMessage", null) + .WithMany("ReactionRoles") + .HasForeignKey("ReactionRoleMessageId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_reactionrole_reactionrolemessage_reactionrolemessageid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRoleMessage", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithMany("ReactionRoleMessages") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reactionrolemessage_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntry", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("ShopEntries") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_shopentry_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntryItem", b => + { + b.HasOne("EllieBot.Services.Database.Models.ShopEntry", null) + .WithMany("Items") + .HasForeignKey("ShopEntryId") + .HasConstraintName("fk_shopentryitem_shopentry_shopentryid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SlowmodeIgnoredRole", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredRoles") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_slowmodeignoredrole_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.SlowmodeIgnoredUser", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredUsers") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_slowmodeignoreduser_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleBlacklistedUser", b => + { + b.HasOne("EllieBot.Services.Database.Models.StreamRoleSettings", null) + .WithMany("Blacklist") + .HasForeignKey("StreamRoleSettingsId") + .HasConstraintName("fk_streamroleblacklisteduser_streamrolesettings_streamrolesett~"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleSettings", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithOne("StreamRole") + .HasForeignKey("EllieBot.Services.Database.Models.StreamRoleSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_streamrolesettings_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleWhitelistedUser", b => + { + b.HasOne("EllieBot.Services.Database.Models.StreamRoleSettings", null) + .WithMany("Whitelist") + .HasForeignKey("StreamRoleSettingsId") + .HasConstraintName("fk_streamrolewhitelisteduser_streamrolesettings_streamrolesett~"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnbanTimer", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("UnbanTimer") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_unbantimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnmuteTimer", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("UnmuteTimers") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_unmutetimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.UnroleTimer", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("UnroleTimer") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_unroletimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.VcRoleInfo", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("VcRoleInfos") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_vcroleinfo_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Affinity") + .WithMany() + .HasForeignKey("AffinityId") + .HasConstraintName("fk_waifuinfo_discorduser_affinityid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Claimer") + .WithMany() + .HasForeignKey("ClaimerId") + .HasConstraintName("fk_waifuinfo_discorduser_claimerid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Waifu") + .WithOne() + .HasForeignKey("EllieBot.Services.Database.Models.WaifuInfo", "WaifuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_waifuinfo_discorduser_waifuid"); + + b.Navigation("Affinity"); + + b.Navigation("Claimer"); + + b.Navigation("Waifu"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuItem", b => + { + b.HasOne("EllieBot.Services.Database.Models.WaifuInfo", "WaifuInfo") + .WithMany("Items") + .HasForeignKey("WaifuInfoId") + .HasConstraintName("fk_waifuitem_waifuinfo_waifuinfoid"); + + b.Navigation("WaifuInfo"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuUpdate", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "New") + .WithMany() + .HasForeignKey("NewId") + .HasConstraintName("fk_waifuupdates_discorduser_newid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Old") + .WithMany() + .HasForeignKey("OldId") + .HasConstraintName("fk_waifuupdates_discorduser_oldid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_waifuupdates_discorduser_userid"); + + b.Navigation("New"); + + b.Navigation("Old"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WarningPunishment", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", null) + .WithMany("WarnPunishments") + .HasForeignKey("GuildConfigId") + .HasConstraintName("fk_warningpunishment_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpCurrencyReward", b => + { + b.HasOne("EllieBot.Services.Database.Models.XpSettings", "XpSettings") + .WithMany("CurrencyRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xpcurrencyreward_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpRoleReward", b => + { + b.HasOne("EllieBot.Services.Database.Models.XpSettings", "XpSettings") + .WithMany("RoleRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xprolereward_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpSettings", b => + { + b.HasOne("EllieBot.Services.Database.Models.GuildConfig", "GuildConfig") + .WithOne("XpSettings") + .HasForeignKey("EllieBot.Services.Database.Models.XpSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xpsettings_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Navigation("Applicants"); + + b.Navigation("Bans"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AntiSpamSetting", b => + { + b.Navigation("IgnoredChannels"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.AutoTranslateChannel", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.GuildConfig", b => + { + b.Navigation("AntiAltSetting"); + + b.Navigation("AntiRaidSetting"); + + b.Navigation("AntiSpamSetting"); + + b.Navigation("CommandAliases"); + + b.Navigation("CommandCooldowns"); + + b.Navigation("DelMsgOnCmdChannels"); + + b.Navigation("FeedSubs"); + + b.Navigation("FilterInvitesChannelIds"); + + b.Navigation("FilterLinksChannelIds"); + + b.Navigation("FilterWordsChannelIds"); + + b.Navigation("FilteredWords"); + + b.Navigation("FollowedStreams"); + + b.Navigation("GenerateCurrencyChannelIds"); + + b.Navigation("MutedUsers"); + + b.Navigation("Permissions"); + + b.Navigation("ReactionRoleMessages"); + + b.Navigation("SelfAssignableRoleGroupNames"); + + b.Navigation("ShopEntries"); + + b.Navigation("SlowmodeIgnoredRoles"); + + b.Navigation("SlowmodeIgnoredUsers"); + + b.Navigation("StreamRole"); + + b.Navigation("UnbanTimer"); + + b.Navigation("UnmuteTimers"); + + b.Navigation("UnroleTimer"); + + b.Navigation("VcRoleInfos"); + + b.Navigation("WarnPunishments"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.LogSetting", b => + { + b.Navigation("LogIgnores"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.MusicPlaylist", b => + { + b.Navigation("Songs"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.Poll", b => + { + b.Navigation("Answers"); + + b.Navigation("Votes"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ReactionRoleMessage", b => + { + b.Navigation("ReactionRoles"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.ShopEntry", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.StreamRoleSettings", b => + { + b.Navigation("Blacklist"); + + b.Navigation("Whitelist"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.WaifuInfo", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Services.Database.Models.XpSettings", b => + { + b.Navigation("CurrencyRewards"); + + b.Navigation("ExclusionList"); + + b.Navigation("RoleRewards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EllieBot/Migrations/PostgreSql/20220409170719_mysql-init.cs b/src/EllieBot/Migrations/PostgreSql/20220409170719_mysql-init.cs new file mode 100644 index 0000000..dafb727 --- /dev/null +++ b/src/EllieBot/Migrations/PostgreSql/20220409170719_mysql-init.cs @@ -0,0 +1,2200 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EllieBot.Migrations.PostgreSql; + +public partial class mysqlinit : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "autocommands", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + commandtext = table.Column(type: "text", nullable: true), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + channelname = table.Column(type: "text", nullable: true), + guildid = table.Column(type: "numeric(20,0)", nullable: true), + guildname = table.Column(type: "text", nullable: true), + voicechannelid = table.Column(type: "numeric(20,0)", nullable: true), + voicechannelname = table.Column(type: "text", nullable: true), + interval = table.Column(type: "integer", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_autocommands", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "autotranslatechannels", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + autodelete = table.Column(type: "boolean", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_autotranslatechannels", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "bantemplates", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + text = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_bantemplates", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "blacklist", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + itemid = table.Column(type: "numeric(20,0)", nullable: false), + type = table.Column(type: "integer", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_blacklist", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "currencytransactions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + amount = table.Column(type: "bigint", nullable: false), + note = table.Column(type: "text", nullable: true), + userid = table.Column(type: "numeric(20,0)", nullable: false), + type = table.Column(type: "text", nullable: false), + extra = table.Column(type: "text", nullable: false), + otherid = table.Column(type: "numeric(20,0)", nullable: true, defaultValueSql: "NULL"), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_currencytransactions", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "discordpermoverrides", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + perm = table.Column(type: "numeric(20,0)", nullable: false), + guildid = table.Column(type: "numeric(20,0)", nullable: true), + command = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_discordpermoverrides", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "expressions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: true), + response = table.Column(type: "text", nullable: true), + trigger = table.Column(type: "text", nullable: true), + autodeletetrigger = table.Column(type: "boolean", nullable: false), + dmresponse = table.Column(type: "boolean", nullable: false), + containsanywhere = table.Column(type: "boolean", nullable: false), + allowtarget = table.Column(type: "boolean", nullable: false), + reactions = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_expressions", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "guildconfigs", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + prefix = table.Column(type: "text", nullable: true), + deletemessageoncommand = table.Column(type: "boolean", nullable: false), + autoassignroleids = table.Column(type: "text", nullable: true), + autodeletegreetmessagestimer = table.Column(type: "integer", nullable: false), + autodeletebyemessagestimer = table.Column(type: "integer", nullable: false), + greetmessagechannelid = table.Column(type: "numeric(20,0)", nullable: false), + byemessagechannelid = table.Column(type: "numeric(20,0)", nullable: false), + senddmgreetmessage = table.Column(type: "boolean", nullable: false), + dmgreetmessagetext = table.Column(type: "text", nullable: true), + sendchannelgreetmessage = table.Column(type: "boolean", nullable: false), + channelgreetmessagetext = table.Column(type: "text", nullable: true), + sendchannelbyemessage = table.Column(type: "boolean", nullable: false), + channelbyemessagetext = table.Column(type: "text", nullable: true), + exclusiveselfassignedroles = table.Column(type: "boolean", nullable: false), + autodeleteselfassignedrolemessages = table.Column(type: "boolean", nullable: false), + verbosepermissions = table.Column(type: "boolean", nullable: false), + permissionrole = table.Column(type: "text", nullable: true), + filterinvites = table.Column(type: "boolean", nullable: false), + filterlinks = table.Column(type: "boolean", nullable: false), + filterwords = table.Column(type: "boolean", nullable: false), + muterolename = table.Column(type: "text", nullable: true), + cleverbotenabled = table.Column(type: "boolean", nullable: false), + locale = table.Column(type: "text", nullable: true), + timezoneid = table.Column(type: "text", nullable: true), + warningsinitialized = table.Column(type: "boolean", nullable: false), + gamevoicechannel = table.Column(type: "numeric(20,0)", nullable: true), + verboseerrors = table.Column(type: "boolean", nullable: false), + notifystreamoffline = table.Column(type: "boolean", nullable: false), + warnexpirehours = table.Column(type: "integer", nullable: false), + warnexpireaction = table.Column(type: "integer", nullable: false), + sendboostmessage = table.Column(type: "boolean", nullable: false), + boostmessage = table.Column(type: "text", nullable: true), + boostmessagechannelid = table.Column(type: "numeric(20,0)", nullable: false), + boostmessagedeleteafter = table.Column(type: "integer", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_guildconfigs", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "imageonlychannels", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_imageonlychannels", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "logsettings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + logotherid = table.Column(type: "numeric(20,0)", nullable: true), + messageupdatedid = table.Column(type: "numeric(20,0)", nullable: true), + messagedeletedid = table.Column(type: "numeric(20,0)", nullable: true), + userjoinedid = table.Column(type: "numeric(20,0)", nullable: true), + userleftid = table.Column(type: "numeric(20,0)", nullable: true), + userbannedid = table.Column(type: "numeric(20,0)", nullable: true), + userunbannedid = table.Column(type: "numeric(20,0)", nullable: true), + userupdatedid = table.Column(type: "numeric(20,0)", nullable: true), + channelcreatedid = table.Column(type: "numeric(20,0)", nullable: true), + channeldestroyedid = table.Column(type: "numeric(20,0)", nullable: true), + channelupdatedid = table.Column(type: "numeric(20,0)", nullable: true), + usermutedid = table.Column(type: "numeric(20,0)", nullable: true), + loguserpresenceid = table.Column(type: "numeric(20,0)", nullable: true), + logvoicepresenceid = table.Column(type: "numeric(20,0)", nullable: true), + logvoicepresencettsid = table.Column(type: "numeric(20,0)", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_logsettings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "musicplayersettings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + playerrepeat = table.Column(type: "integer", nullable: false), + musicchannelid = table.Column(type: "numeric(20,0)", nullable: true), + volume = table.Column(type: "integer", nullable: false, defaultValue: 100), + autodisconnect = table.Column(type: "boolean", nullable: false), + qualitypreset = table.Column(type: "integer", nullable: false), + autoplay = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_musicplayersettings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "musicplaylists", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "text", nullable: true), + author = table.Column(type: "text", nullable: true), + authorid = table.Column(type: "numeric(20,0)", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_musicplaylists", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "nsfwblacklistedtags", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + tag = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_nsfwblacklistedtags", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "plantedcurrency", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + amount = table.Column(type: "bigint", nullable: false), + password = table.Column(type: "text", nullable: true), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + userid = table.Column(type: "numeric(20,0)", nullable: false), + messageid = table.Column(type: "numeric(20,0)", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_plantedcurrency", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "poll", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + question = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_poll", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "quotes", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + keyword = table.Column(type: "text", nullable: false), + authorname = table.Column(type: "text", nullable: false), + authorid = table.Column(type: "numeric(20,0)", nullable: false), + text = table.Column(type: "text", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_quotes", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "reminders", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + when = table.Column(type: "timestamp with time zone", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + serverid = table.Column(type: "numeric(20,0)", nullable: false), + userid = table.Column(type: "numeric(20,0)", nullable: false), + message = table.Column(type: "text", nullable: true), + isprivate = table.Column(type: "boolean", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_reminders", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "repeaters", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + lastmessageid = table.Column(type: "numeric(20,0)", nullable: true), + message = table.Column(type: "text", nullable: true), + interval = table.Column(type: "interval", nullable: false), + starttimeofday = table.Column(type: "interval", nullable: true), + noredundant = table.Column(type: "boolean", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_repeaters", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "rewardedusers", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + patreonuserid = table.Column(type: "text", nullable: true), + amountrewardedthismonth = table.Column(type: "integer", nullable: false), + lastreward = table.Column(type: "timestamp with time zone", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_rewardedusers", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "rotatingstatus", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + status = table.Column(type: "text", nullable: true), + type = table.Column(type: "integer", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_rotatingstatus", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "selfassignableroles", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + roleid = table.Column(type: "numeric(20,0)", nullable: false), + group = table.Column(type: "integer", nullable: false, defaultValue: 0), + levelrequirement = table.Column(type: "integer", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_selfassignableroles", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "userxpstats", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + xp = table.Column(type: "integer", nullable: false), + awardedxp = table.Column(type: "integer", nullable: false), + notifyonlevelup = table.Column(type: "integer", nullable: false), + lastlevelup = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "timezone('utc', now())"), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_userxpstats", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "warnings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + userid = table.Column(type: "numeric(20,0)", nullable: false), + reason = table.Column(type: "text", nullable: true), + forgiven = table.Column(type: "boolean", nullable: false), + forgivenby = table.Column(type: "text", nullable: true), + moderator = table.Column(type: "text", nullable: true), + weight = table.Column(type: "bigint", nullable: false, defaultValue: 1L), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_warnings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "autotranslateusers", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + channelid = table.Column(type: "integer", nullable: false), + userid = table.Column(type: "numeric(20,0)", nullable: false), + source = table.Column(type: "text", nullable: true), + target = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_autotranslateusers", x => x.id); + table.UniqueConstraint("ak_autotranslateusers_channelid_userid", x => new { x.channelid, x.userid }); + table.ForeignKey( + name: "fk_autotranslateusers_autotranslatechannels_channelid", + column: x => x.channelid, + principalTable: "autotranslatechannels", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "antialtsetting", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: false), + minage = table.Column(type: "interval", nullable: false), + action = table.Column(type: "integer", nullable: false), + actiondurationminutes = table.Column(type: "integer", nullable: false), + roleid = table.Column(type: "numeric(20,0)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_antialtsetting", x => x.id); + table.ForeignKey( + name: "fk_antialtsetting_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "antiraidsetting", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: false), + userthreshold = table.Column(type: "integer", nullable: false), + seconds = table.Column(type: "integer", nullable: false), + action = table.Column(type: "integer", nullable: false), + punishduration = table.Column(type: "integer", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_antiraidsetting", x => x.id); + table.ForeignKey( + name: "fk_antiraidsetting_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "antispamsetting", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: false), + action = table.Column(type: "integer", nullable: false), + messagethreshold = table.Column(type: "integer", nullable: false), + mutetime = table.Column(type: "integer", nullable: false), + roleid = table.Column(type: "numeric(20,0)", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_antispamsetting", x => x.id); + table.ForeignKey( + name: "fk_antispamsetting_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "commandalias", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + trigger = table.Column(type: "text", nullable: true), + mapping = table.Column(type: "text", nullable: true), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_commandalias", x => x.id); + table.ForeignKey( + name: "fk_commandalias_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "commandcooldown", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + seconds = table.Column(type: "integer", nullable: false), + commandname = table.Column(type: "text", nullable: true), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_commandcooldown", x => x.id); + table.ForeignKey( + name: "fk_commandcooldown_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "delmsgoncmdchannel", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + state = table.Column(type: "boolean", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_delmsgoncmdchannel", x => x.id); + table.ForeignKey( + name: "fk_delmsgoncmdchannel_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "feedsub", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + url = table.Column(type: "text", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_feedsub", x => x.id); + table.UniqueConstraint("ak_feedsub_guildconfigid_url", x => new { x.guildconfigid, x.url }); + table.ForeignKey( + name: "fk_feedsub_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "filterchannelid", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_filterchannelid", x => x.id); + table.ForeignKey( + name: "fk_filterchannelid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "filteredword", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + word = table.Column(type: "text", nullable: true), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_filteredword", x => x.id); + table.ForeignKey( + name: "fk_filteredword_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "filterlinkschannelid", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_filterlinkschannelid", x => x.id); + table.ForeignKey( + name: "fk_filterlinkschannelid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "filterwordschannelid", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_filterwordschannelid", x => x.id); + table.ForeignKey( + name: "fk_filterwordschannelid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "followedstream", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column(type: "numeric(20,0)", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + username = table.Column(type: "text", nullable: true), + type = table.Column(type: "integer", nullable: false), + message = table.Column(type: "text", nullable: true), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_followedstream", x => x.id); + table.ForeignKey( + name: "fk_followedstream_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "gcchannelid", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: true), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_gcchannelid", x => x.id); + table.ForeignKey( + name: "fk_gcchannelid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "groupname", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: false), + number = table.Column(type: "integer", nullable: false), + name = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_groupname", x => x.id); + table.ForeignKey( + name: "fk_groupname_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "muteduserid", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_muteduserid", x => x.id); + table.ForeignKey( + name: "fk_muteduserid_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "permissions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: true), + index = table.Column(type: "integer", nullable: false), + primarytarget = table.Column(type: "integer", nullable: false), + primarytargetid = table.Column(type: "numeric(20,0)", nullable: false), + secondarytarget = table.Column(type: "integer", nullable: false), + secondarytargetname = table.Column(type: "text", nullable: true), + iscustomcommand = table.Column(type: "boolean", nullable: false), + state = table.Column(type: "boolean", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_permissions", x => x.id); + table.ForeignKey( + name: "fk_permissions_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "reactionrolemessage", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + index = table.Column(type: "integer", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: false), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + messageid = table.Column(type: "numeric(20,0)", nullable: false), + exclusive = table.Column(type: "boolean", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_reactionrolemessage", x => x.id); + table.ForeignKey( + name: "fk_reactionrolemessage_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "shopentry", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + index = table.Column(type: "integer", nullable: false), + price = table.Column(type: "integer", nullable: false), + name = table.Column(type: "text", nullable: true), + authorid = table.Column(type: "numeric(20,0)", nullable: false), + type = table.Column(type: "integer", nullable: false), + rolename = table.Column(type: "text", nullable: true), + roleid = table.Column(type: "numeric(20,0)", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_shopentry", x => x.id); + table.ForeignKey( + name: "fk_shopentry_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "slowmodeignoredrole", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + roleid = table.Column(type: "numeric(20,0)", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_slowmodeignoredrole", x => x.id); + table.ForeignKey( + name: "fk_slowmodeignoredrole_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "slowmodeignoreduser", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_slowmodeignoreduser", x => x.id); + table.ForeignKey( + name: "fk_slowmodeignoreduser_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "streamrolesettings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: false), + enabled = table.Column(type: "boolean", nullable: false), + addroleid = table.Column(type: "numeric(20,0)", nullable: false), + fromroleid = table.Column(type: "numeric(20,0)", nullable: false), + keyword = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_streamrolesettings", x => x.id); + table.ForeignKey( + name: "fk_streamrolesettings_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "unbantimer", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + unbanat = table.Column(type: "timestamp with time zone", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_unbantimer", x => x.id); + table.ForeignKey( + name: "fk_unbantimer_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "unmutetimer", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + unmuteat = table.Column(type: "timestamp with time zone", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_unmutetimer", x => x.id); + table.ForeignKey( + name: "fk_unmutetimer_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "unroletimer", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + roleid = table.Column(type: "numeric(20,0)", nullable: false), + unbanat = table.Column(type: "timestamp with time zone", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_unroletimer", x => x.id); + table.ForeignKey( + name: "fk_unroletimer_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "vcroleinfo", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + voicechannelid = table.Column(type: "numeric(20,0)", nullable: false), + roleid = table.Column(type: "numeric(20,0)", nullable: false), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_vcroleinfo", x => x.id); + table.ForeignKey( + name: "fk_vcroleinfo_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "warningpunishment", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + count = table.Column(type: "integer", nullable: false), + punishment = table.Column(type: "integer", nullable: false), + time = table.Column(type: "integer", nullable: false), + roleid = table.Column(type: "numeric(20,0)", nullable: true), + guildconfigid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_warningpunishment", x => x.id); + table.ForeignKey( + name: "fk_warningpunishment_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "xpsettings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildconfigid = table.Column(type: "integer", nullable: false), + serverexcluded = table.Column(type: "boolean", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_xpsettings", x => x.id); + table.ForeignKey( + name: "fk_xpsettings_guildconfigs_guildconfigid", + column: x => x.guildconfigid, + principalTable: "guildconfigs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ignoredlogchannels", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + logsettingid = table.Column(type: "integer", nullable: false), + logitemid = table.Column(type: "numeric(20,0)", nullable: false), + itemtype = table.Column(type: "integer", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_ignoredlogchannels", x => x.id); + table.ForeignKey( + name: "fk_ignoredlogchannels_logsettings_logsettingid", + column: x => x.logsettingid, + principalTable: "logsettings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ignoredvoicepresencechannels", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + logsettingid = table.Column(type: "integer", nullable: true), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_ignoredvoicepresencechannels", x => x.id); + table.ForeignKey( + name: "fk_ignoredvoicepresencechannels_logsettings_logsettingid", + column: x => x.logsettingid, + principalTable: "logsettings", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "playlistsong", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + provider = table.Column(type: "text", nullable: true), + providertype = table.Column(type: "integer", nullable: false), + title = table.Column(type: "text", nullable: true), + uri = table.Column(type: "text", nullable: true), + query = table.Column(type: "text", nullable: true), + musicplaylistid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_playlistsong", x => x.id); + table.ForeignKey( + name: "fk_playlistsong_musicplaylists_musicplaylistid", + column: x => x.musicplaylistid, + principalTable: "musicplaylists", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "pollanswer", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + index = table.Column(type: "integer", nullable: false), + text = table.Column(type: "text", nullable: true), + pollid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_pollanswer", x => x.id); + table.ForeignKey( + name: "fk_pollanswer_poll_pollid", + column: x => x.pollid, + principalTable: "poll", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "pollvote", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + voteindex = table.Column(type: "integer", nullable: false), + pollid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_pollvote", x => x.id); + table.ForeignKey( + name: "fk_pollvote_poll_pollid", + column: x => x.pollid, + principalTable: "poll", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "antispamignore", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + channelid = table.Column(type: "numeric(20,0)", nullable: false), + antispamsettingid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_antispamignore", x => x.id); + table.ForeignKey( + name: "fk_antispamignore_antispamsetting_antispamsettingid", + column: x => x.antispamsettingid, + principalTable: "antispamsetting", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "reactionrole", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + emotename = table.Column(type: "text", nullable: true), + roleid = table.Column(type: "numeric(20,0)", nullable: false), + reactionrolemessageid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_reactionrole", x => x.id); + table.ForeignKey( + name: "fk_reactionrole_reactionrolemessage_reactionrolemessageid", + column: x => x.reactionrolemessageid, + principalTable: "reactionrolemessage", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "shopentryitem", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + text = table.Column(type: "text", nullable: true), + shopentryid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_shopentryitem", x => x.id); + table.ForeignKey( + name: "fk_shopentryitem_shopentry_shopentryid", + column: x => x.shopentryid, + principalTable: "shopentry", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "streamroleblacklisteduser", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + username = table.Column(type: "text", nullable: true), + streamrolesettingsid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_streamroleblacklisteduser", x => x.id); + table.ForeignKey( + name: "fk_streamroleblacklisteduser_streamrolesettings_streamrolesett~", + column: x => x.streamrolesettingsid, + principalTable: "streamrolesettings", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "streamrolewhitelisteduser", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + username = table.Column(type: "text", nullable: true), + streamrolesettingsid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_streamrolewhitelisteduser", x => x.id); + table.ForeignKey( + name: "fk_streamrolewhitelisteduser_streamrolesettings_streamrolesett~", + column: x => x.streamrolesettingsid, + principalTable: "streamrolesettings", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "excludeditem", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + itemid = table.Column(type: "numeric(20,0)", nullable: false), + itemtype = table.Column(type: "integer", nullable: false), + xpsettingsid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_excludeditem", x => x.id); + table.ForeignKey( + name: "fk_excludeditem_xpsettings_xpsettingsid", + column: x => x.xpsettingsid, + principalTable: "xpsettings", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "xpcurrencyreward", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + xpsettingsid = table.Column(type: "integer", nullable: false), + level = table.Column(type: "integer", nullable: false), + amount = table.Column(type: "integer", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_xpcurrencyreward", x => x.id); + table.ForeignKey( + name: "fk_xpcurrencyreward_xpsettings_xpsettingsid", + column: x => x.xpsettingsid, + principalTable: "xpsettings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "xprolereward", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + xpsettingsid = table.Column(type: "integer", nullable: false), + level = table.Column(type: "integer", nullable: false), + roleid = table.Column(type: "numeric(20,0)", nullable: false), + remove = table.Column(type: "boolean", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_xprolereward", x => x.id); + table.ForeignKey( + name: "fk_xprolereward_xpsettings_xpsettingsid", + column: x => x.xpsettingsid, + principalTable: "xpsettings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "clubapplicants", + columns: table => new + { + clubid = table.Column(type: "integer", nullable: false), + userid = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_clubapplicants", x => new { x.clubid, x.userid }); + }); + + migrationBuilder.CreateTable( + name: "clubbans", + columns: table => new + { + clubid = table.Column(type: "integer", nullable: false), + userid = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_clubbans", x => new { x.clubid, x.userid }); + }); + + migrationBuilder.CreateTable( + name: "clubs", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + description = table.Column(type: "text", nullable: true), + imageurl = table.Column(type: "text", nullable: true), + xp = table.Column(type: "integer", nullable: false), + ownerid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_clubs", x => x.id); + table.UniqueConstraint("ak_clubs_name", x => x.name); + }); + + migrationBuilder.CreateTable( + name: "discorduser", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "numeric(20,0)", nullable: false), + username = table.Column(type: "text", nullable: true), + discriminator = table.Column(type: "text", nullable: true), + avatarid = table.Column(type: "text", nullable: true), + clubid = table.Column(type: "integer", nullable: true), + isclubadmin = table.Column(type: "boolean", nullable: false, defaultValue: false), + totalxp = table.Column(type: "integer", nullable: false, defaultValue: 0), + lastlevelup = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "timezone('utc', now())"), + lastxpgain = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "timezone('utc', now()) - interval '-1 year'"), + notifyonlevelup = table.Column(type: "integer", nullable: false, defaultValue: 0), + currencyamount = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_discorduser", x => x.id); + table.UniqueConstraint("ak_discorduser_userid", x => x.userid); + table.ForeignKey( + name: "fk_discorduser_clubs_clubid", + column: x => x.clubid, + principalTable: "clubs", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "waifuinfo", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + waifuid = table.Column(type: "integer", nullable: false), + claimerid = table.Column(type: "integer", nullable: true), + affinityid = table.Column(type: "integer", nullable: true), + price = table.Column(type: "bigint", nullable: false), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_waifuinfo", x => x.id); + table.ForeignKey( + name: "fk_waifuinfo_discorduser_affinityid", + column: x => x.affinityid, + principalTable: "discorduser", + principalColumn: "id"); + table.ForeignKey( + name: "fk_waifuinfo_discorduser_claimerid", + column: x => x.claimerid, + principalTable: "discorduser", + principalColumn: "id"); + table.ForeignKey( + name: "fk_waifuinfo_discorduser_waifuid", + column: x => x.waifuid, + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "waifuupdates", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + userid = table.Column(type: "integer", nullable: false), + updatetype = table.Column(type: "integer", nullable: false), + oldid = table.Column(type: "integer", nullable: true), + newid = table.Column(type: "integer", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_waifuupdates", x => x.id); + table.ForeignKey( + name: "fk_waifuupdates_discorduser_newid", + column: x => x.newid, + principalTable: "discorduser", + principalColumn: "id"); + table.ForeignKey( + name: "fk_waifuupdates_discorduser_oldid", + column: x => x.oldid, + principalTable: "discorduser", + principalColumn: "id"); + table.ForeignKey( + name: "fk_waifuupdates_discorduser_userid", + column: x => x.userid, + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "waifuitem", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + waifuinfoid = table.Column(type: "integer", nullable: true), + itememoji = table.Column(type: "text", nullable: true), + name = table.Column(type: "text", nullable: true), + dateadded = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_waifuitem", x => x.id); + table.ForeignKey( + name: "fk_waifuitem_waifuinfo_waifuinfoid", + column: x => x.waifuinfoid, + principalTable: "waifuinfo", + principalColumn: "id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_antialtsetting_guildconfigid", + table: "antialtsetting", + column: "guildconfigid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_antiraidsetting_guildconfigid", + table: "antiraidsetting", + column: "guildconfigid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_antispamignore_antispamsettingid", + table: "antispamignore", + column: "antispamsettingid"); + + migrationBuilder.CreateIndex( + name: "ix_antispamsetting_guildconfigid", + table: "antispamsetting", + column: "guildconfigid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_autotranslatechannels_channelid", + table: "autotranslatechannels", + column: "channelid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_autotranslatechannels_guildid", + table: "autotranslatechannels", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_bantemplates_guildid", + table: "bantemplates", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_clubapplicants_userid", + table: "clubapplicants", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_clubbans_userid", + table: "clubbans", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_clubs_ownerid", + table: "clubs", + column: "ownerid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_commandalias_guildconfigid", + table: "commandalias", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_commandcooldown_guildconfigid", + table: "commandcooldown", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_currencytransactions_userid", + table: "currencytransactions", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_delmsgoncmdchannel_guildconfigid", + table: "delmsgoncmdchannel", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_discordpermoverrides_guildid_command", + table: "discordpermoverrides", + columns: new[] { "guildid", "command" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_discorduser_clubid", + table: "discorduser", + column: "clubid"); + + migrationBuilder.CreateIndex( + name: "ix_discorduser_currencyamount", + table: "discorduser", + column: "currencyamount"); + + migrationBuilder.CreateIndex( + name: "ix_discorduser_totalxp", + table: "discorduser", + column: "totalxp"); + + migrationBuilder.CreateIndex( + name: "ix_discorduser_userid", + table: "discorduser", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_excludeditem_xpsettingsid", + table: "excludeditem", + column: "xpsettingsid"); + + migrationBuilder.CreateIndex( + name: "ix_filterchannelid_guildconfigid", + table: "filterchannelid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_filteredword_guildconfigid", + table: "filteredword", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_filterlinkschannelid_guildconfigid", + table: "filterlinkschannelid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_filterwordschannelid_guildconfigid", + table: "filterwordschannelid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_followedstream_guildconfigid", + table: "followedstream", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_gcchannelid_guildconfigid", + table: "gcchannelid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_groupname_guildconfigid_number", + table: "groupname", + columns: new[] { "guildconfigid", "number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_guildconfigs_guildid", + table: "guildconfigs", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_guildconfigs_warnexpirehours", + table: "guildconfigs", + column: "warnexpirehours"); + + migrationBuilder.CreateIndex( + name: "ix_ignoredlogchannels_logsettingid_logitemid_itemtype", + table: "ignoredlogchannels", + columns: new[] { "logsettingid", "logitemid", "itemtype" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_ignoredvoicepresencechannels_logsettingid", + table: "ignoredvoicepresencechannels", + column: "logsettingid"); + + migrationBuilder.CreateIndex( + name: "ix_imageonlychannels_channelid", + table: "imageonlychannels", + column: "channelid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_logsettings_guildid", + table: "logsettings", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_musicplayersettings_guildid", + table: "musicplayersettings", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_muteduserid_guildconfigid", + table: "muteduserid", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_nsfwblacklistedtags_guildid", + table: "nsfwblacklistedtags", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_permissions_guildconfigid", + table: "permissions", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_plantedcurrency_channelid", + table: "plantedcurrency", + column: "channelid"); + + migrationBuilder.CreateIndex( + name: "ix_plantedcurrency_messageid", + table: "plantedcurrency", + column: "messageid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_playlistsong_musicplaylistid", + table: "playlistsong", + column: "musicplaylistid"); + + migrationBuilder.CreateIndex( + name: "ix_poll_guildid", + table: "poll", + column: "guildid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_pollanswer_pollid", + table: "pollanswer", + column: "pollid"); + + migrationBuilder.CreateIndex( + name: "ix_pollvote_pollid", + table: "pollvote", + column: "pollid"); + + migrationBuilder.CreateIndex( + name: "ix_quotes_guildid", + table: "quotes", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_quotes_keyword", + table: "quotes", + column: "keyword"); + + migrationBuilder.CreateIndex( + name: "ix_reactionrole_reactionrolemessageid", + table: "reactionrole", + column: "reactionrolemessageid"); + + migrationBuilder.CreateIndex( + name: "ix_reactionrolemessage_guildconfigid", + table: "reactionrolemessage", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_reminders_when", + table: "reminders", + column: "when"); + + migrationBuilder.CreateIndex( + name: "ix_rewardedusers_patreonuserid", + table: "rewardedusers", + column: "patreonuserid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_selfassignableroles_guildid_roleid", + table: "selfassignableroles", + columns: new[] { "guildid", "roleid" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_shopentry_guildconfigid", + table: "shopentry", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_shopentryitem_shopentryid", + table: "shopentryitem", + column: "shopentryid"); + + migrationBuilder.CreateIndex( + name: "ix_slowmodeignoredrole_guildconfigid", + table: "slowmodeignoredrole", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_slowmodeignoreduser_guildconfigid", + table: "slowmodeignoreduser", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_streamroleblacklisteduser_streamrolesettingsid", + table: "streamroleblacklisteduser", + column: "streamrolesettingsid"); + + migrationBuilder.CreateIndex( + name: "ix_streamrolesettings_guildconfigid", + table: "streamrolesettings", + column: "guildconfigid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_streamrolewhitelisteduser_streamrolesettingsid", + table: "streamrolewhitelisteduser", + column: "streamrolesettingsid"); + + migrationBuilder.CreateIndex( + name: "ix_unbantimer_guildconfigid", + table: "unbantimer", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_unmutetimer_guildconfigid", + table: "unmutetimer", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_unroletimer_guildconfigid", + table: "unroletimer", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_awardedxp", + table: "userxpstats", + column: "awardedxp"); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_guildid", + table: "userxpstats", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_userid", + table: "userxpstats", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_userid_guildid", + table: "userxpstats", + columns: new[] { "userid", "guildid" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_userxpstats_xp", + table: "userxpstats", + column: "xp"); + + migrationBuilder.CreateIndex( + name: "ix_vcroleinfo_guildconfigid", + table: "vcroleinfo", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuinfo_affinityid", + table: "waifuinfo", + column: "affinityid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuinfo_claimerid", + table: "waifuinfo", + column: "claimerid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuinfo_price", + table: "waifuinfo", + column: "price"); + + migrationBuilder.CreateIndex( + name: "ix_waifuinfo_waifuid", + table: "waifuinfo", + column: "waifuid", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_waifuitem_waifuinfoid", + table: "waifuitem", + column: "waifuinfoid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuupdates_newid", + table: "waifuupdates", + column: "newid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuupdates_oldid", + table: "waifuupdates", + column: "oldid"); + + migrationBuilder.CreateIndex( + name: "ix_waifuupdates_userid", + table: "waifuupdates", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_warningpunishment_guildconfigid", + table: "warningpunishment", + column: "guildconfigid"); + + migrationBuilder.CreateIndex( + name: "ix_warnings_dateadded", + table: "warnings", + column: "dateadded"); + + migrationBuilder.CreateIndex( + name: "ix_warnings_guildid", + table: "warnings", + column: "guildid"); + + migrationBuilder.CreateIndex( + name: "ix_warnings_userid", + table: "warnings", + column: "userid"); + + migrationBuilder.CreateIndex( + name: "ix_xpcurrencyreward_xpsettingsid", + table: "xpcurrencyreward", + column: "xpsettingsid"); + + migrationBuilder.CreateIndex( + name: "ix_xprolereward_xpsettingsid_level", + table: "xprolereward", + columns: new[] { "xpsettingsid", "level" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_xpsettings_guildconfigid", + table: "xpsettings", + column: "guildconfigid", + unique: true); + + migrationBuilder.AddForeignKey( + name: "fk_clubapplicants_clubs_clubid", + table: "clubapplicants", + column: "clubid", + principalTable: "clubs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_clubapplicants_discorduser_userid", + table: "clubapplicants", + column: "userid", + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_clubbans_clubs_clubid", + table: "clubbans", + column: "clubid", + principalTable: "clubs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_clubbans_discorduser_userid", + table: "clubbans", + column: "userid", + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_clubs_discorduser_ownerid", + table: "clubs", + column: "ownerid", + principalTable: "discorduser", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_discorduser_clubs_clubid", + table: "discorduser"); + + migrationBuilder.DropTable( + name: "antialtsetting"); + + migrationBuilder.DropTable( + name: "antiraidsetting"); + + migrationBuilder.DropTable( + name: "antispamignore"); + + migrationBuilder.DropTable( + name: "autocommands"); + + migrationBuilder.DropTable( + name: "autotranslateusers"); + + migrationBuilder.DropTable( + name: "bantemplates"); + + migrationBuilder.DropTable( + name: "blacklist"); + + migrationBuilder.DropTable( + name: "clubapplicants"); + + migrationBuilder.DropTable( + name: "clubbans"); + + migrationBuilder.DropTable( + name: "commandalias"); + + migrationBuilder.DropTable( + name: "commandcooldown"); + + migrationBuilder.DropTable( + name: "currencytransactions"); + + migrationBuilder.DropTable( + name: "delmsgoncmdchannel"); + + migrationBuilder.DropTable( + name: "discordpermoverrides"); + + migrationBuilder.DropTable( + name: "excludeditem"); + + migrationBuilder.DropTable( + name: "expressions"); + + migrationBuilder.DropTable( + name: "feedsub"); + + migrationBuilder.DropTable( + name: "filterchannelid"); + + migrationBuilder.DropTable( + name: "filteredword"); + + migrationBuilder.DropTable( + name: "filterlinkschannelid"); + + migrationBuilder.DropTable( + name: "filterwordschannelid"); + + migrationBuilder.DropTable( + name: "followedstream"); + + migrationBuilder.DropTable( + name: "gcchannelid"); + + migrationBuilder.DropTable( + name: "groupname"); + + migrationBuilder.DropTable( + name: "ignoredlogchannels"); + + migrationBuilder.DropTable( + name: "ignoredvoicepresencechannels"); + + migrationBuilder.DropTable( + name: "imageonlychannels"); + + migrationBuilder.DropTable( + name: "musicplayersettings"); + + migrationBuilder.DropTable( + name: "muteduserid"); + + migrationBuilder.DropTable( + name: "nsfwblacklistedtags"); + + migrationBuilder.DropTable( + name: "permissions"); + + migrationBuilder.DropTable( + name: "plantedcurrency"); + + migrationBuilder.DropTable( + name: "playlistsong"); + + migrationBuilder.DropTable( + name: "pollanswer"); + + migrationBuilder.DropTable( + name: "pollvote"); + + migrationBuilder.DropTable( + name: "quotes"); + + migrationBuilder.DropTable( + name: "reactionrole"); + + migrationBuilder.DropTable( + name: "reminders"); + + migrationBuilder.DropTable( + name: "repeaters"); + + migrationBuilder.DropTable( + name: "rewardedusers"); + + migrationBuilder.DropTable( + name: "rotatingstatus"); + + migrationBuilder.DropTable( + name: "selfassignableroles"); + + migrationBuilder.DropTable( + name: "shopentryitem"); + + migrationBuilder.DropTable( + name: "slowmodeignoredrole"); + + migrationBuilder.DropTable( + name: "slowmodeignoreduser"); + + migrationBuilder.DropTable( + name: "streamroleblacklisteduser"); + + migrationBuilder.DropTable( + name: "streamrolewhitelisteduser"); + + migrationBuilder.DropTable( + name: "unbantimer"); + + migrationBuilder.DropTable( + name: "unmutetimer"); + + migrationBuilder.DropTable( + name: "unroletimer"); + + migrationBuilder.DropTable( + name: "userxpstats"); + + migrationBuilder.DropTable( + name: "vcroleinfo"); + + migrationBuilder.DropTable( + name: "waifuitem"); + + migrationBuilder.DropTable( + name: "waifuupdates"); + + migrationBuilder.DropTable( + name: "warningpunishment"); + + migrationBuilder.DropTable( + name: "warnings"); + + migrationBuilder.DropTable( + name: "xpcurrencyreward"); + + migrationBuilder.DropTable( + name: "xprolereward"); + + migrationBuilder.DropTable( + name: "antispamsetting"); + + migrationBuilder.DropTable( + name: "autotranslatechannels"); + + migrationBuilder.DropTable( + name: "logsettings"); + + migrationBuilder.DropTable( + name: "musicplaylists"); + + migrationBuilder.DropTable( + name: "poll"); + + migrationBuilder.DropTable( + name: "reactionrolemessage"); + + migrationBuilder.DropTable( + name: "shopentry"); + + migrationBuilder.DropTable( + name: "streamrolesettings"); + + migrationBuilder.DropTable( + name: "waifuinfo"); + + migrationBuilder.DropTable( + name: "xpsettings"); + + migrationBuilder.DropTable( + name: "guildconfigs"); + + migrationBuilder.DropTable( + name: "clubs"); + + migrationBuilder.DropTable( + name: "discorduser"); + } +} diff --git a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs new file mode 100644 index 0000000..ba9a2b9 --- /dev/null +++ b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs @@ -0,0 +1,3812 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using EllieBot.Db; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EllieBot.Migrations.PostgreSql; + +[DbContext(typeof(PostgreSqlContext))] +partial class PostgreSqlContextModelSnapshot : ModelSnapshot +{ + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EllieBot.Db.Models.AntiAltSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasColumnName("action"); + + b.Property("ActionDurationMinutes") + .HasColumnType("integer") + .HasColumnName("actiondurationminutes"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("MinAge") + .HasColumnType("interval") + .HasColumnName("minage"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_antialtsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antialtsetting_guildconfigid"); + + b.ToTable("antialtsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiRaidSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasColumnName("action"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("PunishDuration") + .HasColumnType("integer") + .HasColumnName("punishduration"); + + b.Property("Seconds") + .HasColumnType("integer") + .HasColumnName("seconds"); + + b.Property("UserThreshold") + .HasColumnType("integer") + .HasColumnName("userthreshold"); + + b.HasKey("Id") + .HasName("pk_antiraidsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antiraidsetting_guildconfigid"); + + b.ToTable("antiraidsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamIgnore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AntiSpamSettingId") + .HasColumnType("integer") + .HasColumnName("antispamsettingid"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.HasKey("Id") + .HasName("pk_antispamignore"); + + b.HasIndex("AntiSpamSettingId") + .HasDatabaseName("ix_antispamignore_antispamsettingid"); + + b.ToTable("antispamignore", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasColumnName("action"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("MessageThreshold") + .HasColumnType("integer") + .HasColumnName("messagethreshold"); + + b.Property("MuteTime") + .HasColumnType("integer") + .HasColumnName("mutetime"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_antispamsetting"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_antispamsetting_guildconfigid"); + + b.ToTable("antispamsetting", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ArchivedTodoListModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_todosarchive"); + + b.ToTable("todosarchive", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("ChannelName") + .HasColumnType("text") + .HasColumnName("channelname"); + + b.Property("CommandText") + .HasColumnType("text") + .HasColumnName("commandtext"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("GuildName") + .HasColumnType("text") + .HasColumnName("guildname"); + + b.Property("Interval") + .HasColumnType("integer") + .HasColumnName("interval"); + + b.Property("VoiceChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("voicechannelid"); + + b.Property("VoiceChannelName") + .HasColumnType("text") + .HasColumnName("voicechannelname"); + + b.HasKey("Id") + .HasName("pk_autocommands"); + + b.ToTable("autocommands", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoPublishChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.HasKey("Id") + .HasName("pk_autopublishchannel"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_autopublishchannel_guildid"); + + b.ToTable("autopublishchannel", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoDelete") + .HasColumnType("boolean") + .HasColumnName("autodelete"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.HasKey("Id") + .HasName("pk_autotranslatechannels"); + + b.HasIndex("ChannelId") + .IsUnique() + .HasDatabaseName("ix_autotranslatechannels_channelid"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_autotranslatechannels_guildid"); + + b.ToTable("autotranslatechannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Source") + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Target") + .HasColumnType("text") + .HasColumnName("target"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_autotranslateusers"); + + b.HasAlternateKey("ChannelId", "UserId") + .HasName("ak_autotranslateusers_channelid_userid"); + + b.ToTable("autotranslateusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BanTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("PruneDays") + .HasColumnType("integer") + .HasColumnName("prunedays"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_bantemplates"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_bantemplates_guildid"); + + b.ToTable("bantemplates", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BankUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Balance") + .HasColumnType("bigint") + .HasColumnName("balance"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_bankusers"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_bankusers_userid"); + + b.ToTable("bankusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BlacklistEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("ItemId") + .HasColumnType("numeric(20,0)") + .HasColumnName("itemid"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_blacklist"); + + b.ToTable("blacklist", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.Property("ClubId") + .HasColumnType("integer") + .HasColumnName("clubid"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("userid"); + + b.HasKey("ClubId", "UserId") + .HasName("pk_clubapplicants"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_clubapplicants_userid"); + + b.ToTable("clubapplicants", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.Property("ClubId") + .HasColumnType("integer") + .HasColumnName("clubid"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("userid"); + + b.HasKey("ClubId", "UserId") + .HasName("pk_clubbans"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_clubbans_userid"); + + b.ToTable("clubbans", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ImageUrl") + .HasColumnType("text") + .HasColumnName("imageurl"); + + b.Property("Name") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("integer") + .HasColumnName("ownerid"); + + b.Property("Xp") + .HasColumnType("integer") + .HasColumnName("xp"); + + b.HasKey("Id") + .HasName("pk_clubs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_clubs_name"); + + b.HasIndex("OwnerId") + .IsUnique() + .HasDatabaseName("ix_clubs_ownerid"); + + b.ToTable("clubs", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Mapping") + .HasColumnType("text") + .HasColumnName("mapping"); + + b.Property("Trigger") + .HasColumnType("text") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("pk_commandalias"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_commandalias_guildconfigid"); + + b.ToTable("commandalias", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandCooldown", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommandName") + .HasColumnType("text") + .HasColumnName("commandname"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Seconds") + .HasColumnType("integer") + .HasColumnName("seconds"); + + b.HasKey("Id") + .HasName("pk_commandcooldown"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_commandcooldown_guildconfigid"); + + b.ToTable("commandcooldown", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CurrencyTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("text") + .HasColumnName("extra"); + + b.Property("Note") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property("OtherId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("otherid") + .HasDefaultValueSql("NULL"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_currencytransactions"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_currencytransactions_userid"); + + b.ToTable("currencytransactions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DelMsgOnCmdChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("State") + .HasColumnType("boolean") + .HasColumnName("state"); + + b.HasKey("Id") + .HasName("pk_delmsgoncmdchannel"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_delmsgoncmdchannel_guildconfigid"); + + b.ToTable("delmsgoncmdchannel", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordPermOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Command") + .HasColumnType("text") + .HasColumnName("command"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Perm") + .HasColumnType("numeric(20,0)") + .HasColumnName("perm"); + + b.HasKey("Id") + .HasName("pk_discordpermoverrides"); + + b.HasIndex("GuildId", "Command") + .IsUnique() + .HasDatabaseName("ix_discordpermoverrides_guildid_command"); + + b.ToTable("discordpermoverrides", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarId") + .HasColumnType("text") + .HasColumnName("avatarid"); + + b.Property("ClubId") + .HasColumnType("integer") + .HasColumnName("clubid"); + + b.Property("CurrencyAmount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("currencyamount"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Discriminator") + .HasColumnType("text") + .HasColumnName("discriminator"); + + b.Property("IsClubAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("isclubadmin"); + + b.Property("NotifyOnLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("notifyonlevelup"); + + b.Property("TotalXp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("totalxp"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_discorduser"); + + b.HasAlternateKey("UserId") + .HasName("ak_discorduser_userid"); + + b.HasIndex("ClubId") + .HasDatabaseName("ix_discorduser_clubid"); + + b.HasIndex("CurrencyAmount") + .HasDatabaseName("ix_discorduser_currencyamount"); + + b.HasIndex("TotalXp") + .HasDatabaseName("ix_discorduser_totalxp"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_discorduser_userid"); + + b.ToTable("discorduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("ItemId") + .HasColumnType("numeric(20,0)") + .HasColumnName("itemid"); + + b.Property("ItemType") + .HasColumnType("integer") + .HasColumnName("itemtype"); + + b.Property("XpSettingsId") + .HasColumnType("integer") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_excludeditem"); + + b.HasIndex("XpSettingsId") + .HasDatabaseName("ix_excludeditem_xpsettingsid"); + + b.ToTable("excludeditem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_feedsub"); + + b.HasAlternateKey("GuildConfigId", "Url") + .HasName("ak_feedsub_guildconfigid_url"); + + b.ToTable("feedsub", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterchannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterchannelid_guildconfigid"); + + b.ToTable("filterchannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterLinksChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterlinkschannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterlinkschannelid_guildconfigid"); + + b.ToTable("filterlinkschannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterWordsChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_filterwordschannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filterwordschannelid_guildconfigid"); + + b.ToTable("filterwordschannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilteredWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Word") + .HasColumnType("text") + .HasColumnName("word"); + + b.HasKey("Id") + .HasName("pk_filteredword"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_filteredword_guildconfigid"); + + b.ToTable("filteredword", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_followedstream"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_followedstream_guildconfigid"); + + b.ToTable("followedstream", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GCChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.HasKey("Id") + .HasName("pk_gcchannelid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_gcchannelid_guildconfigid"); + + b.ToTable("gcchannelid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GamblingStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bet") + .HasColumnType("numeric") + .HasColumnName("bet"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Feature") + .HasColumnType("text") + .HasColumnName("feature"); + + b.Property("PaidOut") + .HasColumnType("numeric") + .HasColumnName("paidout"); + + b.HasKey("Id") + .HasName("pk_gamblingstats"); + + b.HasIndex("Feature") + .IsUnique() + .HasDatabaseName("ix_gamblingstats_feature"); + + b.ToTable("gamblingstats", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("EndsAt") + .HasColumnType("timestamp without time zone") + .HasColumnName("endsat"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("MessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messageid"); + + b.HasKey("Id") + .HasName("pk_giveawaymodel"); + + b.ToTable("giveawaymodel", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GiveawayId") + .HasColumnType("integer") + .HasColumnName("giveawayid"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_giveawayuser"); + + b.HasIndex("GiveawayId", "UserId") + .IsUnique() + .HasDatabaseName("ix_giveawayuser_giveawayid_userid"); + + b.ToTable("giveawayuser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GroupName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.HasKey("Id") + .HasName("pk_groupname"); + + b.HasIndex("GuildConfigId", "Number") + .IsUnique() + .HasDatabaseName("ix_groupname_guildconfigid_number"); + + b.ToTable("groupname", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GuildConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoAssignRoleIds") + .HasColumnType("text") + .HasColumnName("autoassignroleids"); + + b.Property("AutoDeleteByeMessagesTimer") + .HasColumnType("integer") + .HasColumnName("autodeletebyemessagestimer"); + + b.Property("AutoDeleteGreetMessagesTimer") + .HasColumnType("integer") + .HasColumnName("autodeletegreetmessagestimer"); + + b.Property("AutoDeleteSelfAssignedRoleMessages") + .HasColumnType("boolean") + .HasColumnName("autodeleteselfassignedrolemessages"); + + b.Property("BoostMessage") + .HasColumnType("text") + .HasColumnName("boostmessage"); + + b.Property("BoostMessageChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("boostmessagechannelid"); + + b.Property("BoostMessageDeleteAfter") + .HasColumnType("integer") + .HasColumnName("boostmessagedeleteafter"); + + b.Property("ByeMessageChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("byemessagechannelid"); + + b.Property("ChannelByeMessageText") + .HasColumnType("text") + .HasColumnName("channelbyemessagetext"); + + b.Property("ChannelGreetMessageText") + .HasColumnType("text") + .HasColumnName("channelgreetmessagetext"); + + b.Property("CleverbotEnabled") + .HasColumnType("boolean") + .HasColumnName("cleverbotenabled"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("DeleteMessageOnCommand") + .HasColumnType("boolean") + .HasColumnName("deletemessageoncommand"); + + b.Property("DeleteStreamOnlineMessage") + .HasColumnType("boolean") + .HasColumnName("deletestreamonlinemessage"); + + b.Property("DisableGlobalExpressions") + .HasColumnType("boolean") + .HasColumnName("disableglobalexpressions"); + + b.Property("DmGreetMessageText") + .HasColumnType("text") + .HasColumnName("dmgreetmessagetext"); + + b.Property("ExclusiveSelfAssignedRoles") + .HasColumnType("boolean") + .HasColumnName("exclusiveselfassignedroles"); + + b.Property("FilterInvites") + .HasColumnType("boolean") + .HasColumnName("filterinvites"); + + b.Property("FilterLinks") + .HasColumnType("boolean") + .HasColumnName("filterlinks"); + + b.Property("FilterWords") + .HasColumnType("boolean") + .HasColumnName("filterwords"); + + b.Property("GameVoiceChannel") + .HasColumnType("numeric(20,0)") + .HasColumnName("gamevoicechannel"); + + b.Property("GreetMessageChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("greetmessagechannelid"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Locale") + .HasColumnType("text") + .HasColumnName("locale"); + + b.Property("MuteRoleName") + .HasColumnType("text") + .HasColumnName("muterolename"); + + b.Property("NotifyStreamOffline") + .HasColumnType("boolean") + .HasColumnName("notifystreamoffline"); + + b.Property("PermissionRole") + .HasColumnType("text") + .HasColumnName("permissionrole"); + + b.Property("Prefix") + .HasColumnType("text") + .HasColumnName("prefix"); + + b.Property("SendBoostMessage") + .HasColumnType("boolean") + .HasColumnName("sendboostmessage"); + + b.Property("SendChannelByeMessage") + .HasColumnType("boolean") + .HasColumnName("sendchannelbyemessage"); + + b.Property("SendChannelGreetMessage") + .HasColumnType("boolean") + .HasColumnName("sendchannelgreetmessage"); + + b.Property("SendDmGreetMessage") + .HasColumnType("boolean") + .HasColumnName("senddmgreetmessage"); + + b.Property("StickyRoles") + .HasColumnType("boolean") + .HasColumnName("stickyroles"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("timezoneid"); + + b.Property("VerboseErrors") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("verboseerrors"); + + b.Property("VerbosePermissions") + .HasColumnType("boolean") + .HasColumnName("verbosepermissions"); + + b.Property("WarnExpireAction") + .HasColumnType("integer") + .HasColumnName("warnexpireaction"); + + b.Property("WarnExpireHours") + .HasColumnType("integer") + .HasColumnName("warnexpirehours"); + + b.Property("WarningsInitialized") + .HasColumnType("boolean") + .HasColumnName("warningsinitialized"); + + b.HasKey("Id") + .HasName("pk_guildconfigs"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_guildconfigs_guildid"); + + b.HasIndex("WarnExpireHours") + .HasDatabaseName("ix_guildconfigs_warnexpirehours"); + + b.ToTable("guildconfigs", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.IgnoredLogItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("ItemType") + .HasColumnType("integer") + .HasColumnName("itemtype"); + + b.Property("LogItemId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logitemid"); + + b.Property("LogSettingId") + .HasColumnType("integer") + .HasColumnName("logsettingid"); + + b.HasKey("Id") + .HasName("pk_ignoredlogchannels"); + + b.HasIndex("LogSettingId", "LogItemId", "ItemType") + .IsUnique() + .HasDatabaseName("ix_ignoredlogchannels_logsettingid_logitemid_itemtype"); + + b.ToTable("ignoredlogchannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ImageOnlyChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_imageonlychannels"); + + b.HasIndex("ChannelId") + .IsUnique() + .HasDatabaseName("ix_imageonlychannels_channelid"); + + b.ToTable("imageonlychannels", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelCreatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelcreatedid"); + + b.Property("ChannelDestroyedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channeldestroyedid"); + + b.Property("ChannelUpdatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelupdatedid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("LogOtherId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logotherid"); + + b.Property("LogUserPresenceId") + .HasColumnType("numeric(20,0)") + .HasColumnName("loguserpresenceid"); + + b.Property("LogVoicePresenceId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logvoicepresenceid"); + + b.Property("LogVoicePresenceTTSId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logvoicepresencettsid"); + + b.Property("LogWarnsId") + .HasColumnType("numeric(20,0)") + .HasColumnName("logwarnsid"); + + b.Property("MessageDeletedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messagedeletedid"); + + b.Property("MessageUpdatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messageupdatedid"); + + b.Property("ThreadCreatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("threadcreatedid"); + + b.Property("ThreadDeletedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("threaddeletedid"); + + b.Property("UserBannedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userbannedid"); + + b.Property("UserJoinedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userjoinedid"); + + b.Property("UserLeftId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userleftid"); + + b.Property("UserMutedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("usermutedid"); + + b.Property("UserUnbannedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userunbannedid"); + + b.Property("UserUpdatedId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userupdatedid"); + + b.HasKey("Id") + .HasName("pk_logsettings"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_logsettings_guildid"); + + b.ToTable("logsettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlayerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoDisconnect") + .HasColumnType("boolean") + .HasColumnName("autodisconnect"); + + b.Property("AutoPlay") + .HasColumnType("boolean") + .HasColumnName("autoplay"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("MusicChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("musicchannelid"); + + b.Property("PlayerRepeat") + .HasColumnType("integer") + .HasColumnName("playerrepeat"); + + b.Property("QualityPreset") + .HasColumnType("integer") + .HasColumnName("qualitypreset"); + + b.Property("Volume") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasColumnName("volume"); + + b.HasKey("Id") + .HasName("pk_musicplayersettings"); + + b.HasIndex("GuildId") + .IsUnique() + .HasDatabaseName("ix_musicplayersettings_guildid"); + + b.ToTable("musicplayersettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlaylist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("AuthorId") + .HasColumnType("numeric(20,0)") + .HasColumnName("authorid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_musicplaylists"); + + b.ToTable("musicplaylists", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MutedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_muteduserid"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_muteduserid_guildconfigid"); + + b.ToTable("muteduserid", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowTarget") + .HasColumnType("boolean") + .HasColumnName("allowtarget"); + + b.Property("AutoDeleteTrigger") + .HasColumnType("boolean") + .HasColumnName("autodeletetrigger"); + + b.Property("ContainsAnywhere") + .HasColumnType("boolean") + .HasColumnName("containsanywhere"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("DmResponse") + .HasColumnType("boolean") + .HasColumnName("dmresponse"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Reactions") + .HasColumnType("text") + .HasColumnName("reactions"); + + b.Property("Response") + .HasColumnType("text") + .HasColumnName("response"); + + b.Property("Trigger") + .HasColumnType("text") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("pk_expressions"); + + b.ToTable("expressions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PatronQuota", b => + { + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("FeatureType") + .HasColumnType("integer") + .HasColumnName("featuretype"); + + b.Property("Feature") + .HasColumnType("text") + .HasColumnName("feature"); + + b.Property("DailyCount") + .HasColumnType("bigint") + .HasColumnName("dailycount"); + + b.Property("HourlyCount") + .HasColumnType("bigint") + .HasColumnName("hourlycount"); + + b.Property("MonthlyCount") + .HasColumnType("bigint") + .HasColumnName("monthlycount"); + + b.HasKey("UserId", "FeatureType", "Feature") + .HasName("pk_patronquotas"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_patronquotas_userid"); + + b.ToTable("patronquotas", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PatronUser", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amountcents"); + + b.Property("LastCharge") + .HasColumnType("timestamp without time zone") + .HasColumnName("lastcharge"); + + b.Property("UniquePlatformUserId") + .HasColumnType("text") + .HasColumnName("uniqueplatformuserid"); + + b.Property("ValidThru") + .HasColumnType("timestamp without time zone") + .HasColumnName("validthru"); + + b.HasKey("UserId") + .HasName("pk_patrons"); + + b.HasIndex("UniquePlatformUserId") + .IsUnique() + .HasDatabaseName("ix_patrons_uniqueplatformuserid"); + + b.ToTable("patrons", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Permissionv2", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("IsCustomCommand") + .HasColumnType("boolean") + .HasColumnName("iscustomcommand"); + + b.Property("PrimaryTarget") + .HasColumnType("integer") + .HasColumnName("primarytarget"); + + b.Property("PrimaryTargetId") + .HasColumnType("numeric(20,0)") + .HasColumnName("primarytargetid"); + + b.Property("SecondaryTarget") + .HasColumnType("integer") + .HasColumnName("secondarytarget"); + + b.Property("SecondaryTargetName") + .HasColumnType("text") + .HasColumnName("secondarytargetname"); + + b.Property("State") + .HasColumnType("boolean") + .HasColumnName("state"); + + b.HasKey("Id") + .HasName("pk_permissions"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_permissions_guildconfigid"); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlantedCurrency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("MessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messageid"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_plantedcurrency"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_plantedcurrency_channelid"); + + b.HasIndex("MessageId") + .IsUnique() + .HasDatabaseName("ix_plantedcurrency_messageid"); + + b.ToTable("plantedcurrency", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlaylistSong", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("MusicPlaylistId") + .HasColumnType("integer") + .HasColumnName("musicplaylistid"); + + b.Property("Provider") + .HasColumnType("text") + .HasColumnName("provider"); + + b.Property("ProviderType") + .HasColumnType("integer") + .HasColumnName("providertype"); + + b.Property("Query") + .HasColumnType("text") + .HasColumnName("query"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Uri") + .HasColumnType("text") + .HasColumnName("uri"); + + b.HasKey("Id") + .HasName("pk_playlistsong"); + + b.HasIndex("MusicPlaylistId") + .HasDatabaseName("ix_playlistsong_musicplaylistid"); + + b.ToTable("playlistsong", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Quote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("numeric(20,0)") + .HasColumnName("authorid"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("authorname"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Keyword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("keyword"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_quotes"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_quotes_guildid"); + + b.HasIndex("Keyword") + .HasDatabaseName("ix_quotes_keyword"); + + b.ToTable("quotes", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ReactionRoleV2", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Emote") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("emote"); + + b.Property("Group") + .HasColumnType("integer") + .HasColumnName("group"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("LevelReq") + .HasColumnType("integer") + .HasColumnName("levelreq"); + + b.Property("MessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messageid"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_reactionroles"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_reactionroles_guildid"); + + b.HasIndex("MessageId", "Emote") + .IsUnique() + .HasDatabaseName("ix_reactionroles_messageid_emote"); + + b.ToTable("reactionroles", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Reminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("isprivate"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("ServerId") + .HasColumnType("numeric(20,0)") + .HasColumnName("serverid"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("When") + .HasColumnType("timestamp without time zone") + .HasColumnName("when"); + + b.HasKey("Id") + .HasName("pk_reminders"); + + b.HasIndex("When") + .HasDatabaseName("ix_reminders_when"); + + b.ToTable("reminders", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Repeater", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Interval") + .HasColumnType("interval") + .HasColumnName("interval"); + + b.Property("LastMessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("lastmessageid"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("NoRedundant") + .HasColumnType("boolean") + .HasColumnName("noredundant"); + + b.Property("StartTimeOfDay") + .HasColumnType("interval") + .HasColumnName("starttimeofday"); + + b.HasKey("Id") + .HasName("pk_repeaters"); + + b.ToTable("repeaters", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.RewardedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountRewardedThisMonth") + .HasColumnType("bigint") + .HasColumnName("amountrewardedthismonth"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("LastReward") + .HasColumnType("timestamp without time zone") + .HasColumnName("lastreward"); + + b.Property("PlatformUserId") + .HasColumnType("text") + .HasColumnName("platformuserid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_rewardedusers"); + + b.HasIndex("PlatformUserId") + .IsUnique() + .HasDatabaseName("ix_rewardedusers_platformuserid"); + + b.ToTable("rewardedusers", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.RotatingPlayingStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Status") + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_rotatingstatus"); + + b.ToTable("rotatingstatus", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SelfAssignedRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Group") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("group"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("LevelRequirement") + .HasColumnType("integer") + .HasColumnName("levelrequirement"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_selfassignableroles"); + + b.HasIndex("GuildId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_selfassignableroles_guildid_roleid"); + + b.ToTable("selfassignableroles", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("numeric(20,0)") + .HasColumnName("authorid"); + + b.Property("Command") + .HasColumnType("text") + .HasColumnName("command"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Index") + .HasColumnType("integer") + .HasColumnName("index"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Price") + .HasColumnType("integer") + .HasColumnName("price"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("RoleName") + .HasColumnType("text") + .HasColumnName("rolename"); + + b.Property("RoleRequirement") + .HasColumnType("numeric(20,0)") + .HasColumnName("rolerequirement"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_shopentry"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_shopentry_guildconfigid"); + + b.ToTable("shopentry", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("ShopEntryId") + .HasColumnType("integer") + .HasColumnName("shopentryid"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_shopentryitem"); + + b.HasIndex("ShopEntryId") + .HasDatabaseName("ix_shopentryitem_shopentryid"); + + b.ToTable("shopentryitem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.HasKey("Id") + .HasName("pk_slowmodeignoredrole"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_slowmodeignoredrole_guildconfigid"); + + b.ToTable("slowmodeignoredrole", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_slowmodeignoreduser"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_slowmodeignoreduser_guildconfigid"); + + b.ToTable("slowmodeignoreduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StickyRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("RoleIds") + .HasColumnType("text") + .HasColumnName("roleids"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_stickyroles"); + + b.HasIndex("GuildId", "UserId") + .IsUnique() + .HasDatabaseName("ix_stickyroles_guildid_userid"); + + b.ToTable("stickyroles", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamOnlineMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("channelid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("MessageId") + .HasColumnType("numeric(20,0)") + .HasColumnName("messageid"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_streamonlinemessages"); + + b.ToTable("streamonlinemessages", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleBlacklistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("integer") + .HasColumnName("streamrolesettingsid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_streamroleblacklisteduser"); + + b.HasIndex("StreamRoleSettingsId") + .HasDatabaseName("ix_streamroleblacklisteduser_streamrolesettingsid"); + + b.ToTable("streamroleblacklisteduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddRoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("addroleid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("FromRoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("fromroleid"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Keyword") + .HasColumnType("text") + .HasColumnName("keyword"); + + b.HasKey("Id") + .HasName("pk_streamrolesettings"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_streamrolesettings_guildconfigid"); + + b.ToTable("streamrolesettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleWhitelistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("integer") + .HasColumnName("streamrolesettingsid"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_streamrolewhitelisteduser"); + + b.HasIndex("StreamRoleSettingsId") + .HasDatabaseName("ix_streamrolewhitelisteduser_streamrolesettingsid"); + + b.ToTable("streamrolewhitelisteduser", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.TodoModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchiveId") + .HasColumnType("integer") + .HasColumnName("archiveid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("IsDone") + .HasColumnType("boolean") + .HasColumnName("isdone"); + + b.Property("Todo") + .HasColumnType("text") + .HasColumnName("todo"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_todos"); + + b.HasIndex("ArchiveId") + .HasDatabaseName("ix_todos_archiveid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_todos_userid"); + + b.ToTable("todos", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnbanTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("UnbanAt") + .HasColumnType("timestamp without time zone") + .HasColumnName("unbanat"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unbantimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unbantimer_guildconfigid"); + + b.ToTable("unbantimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnmuteTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("UnmuteAt") + .HasColumnType("timestamp without time zone") + .HasColumnName("unmuteat"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unmutetimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unmutetimer_guildconfigid"); + + b.ToTable("unmutetimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnroleTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("UnbanAt") + .HasColumnType("timestamp without time zone") + .HasColumnName("unbanat"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_unroletimer"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_unroletimer_guildconfigid"); + + b.ToTable("unroletimer", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwardedXp") + .HasColumnType("bigint") + .HasColumnName("awardedxp"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("NotifyOnLevelUp") + .HasColumnType("integer") + .HasColumnName("notifyonlevelup"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Xp") + .HasColumnType("bigint") + .HasColumnName("xp"); + + b.HasKey("Id") + .HasName("pk_userxpstats"); + + b.HasIndex("AwardedXp") + .HasDatabaseName("ix_userxpstats_awardedxp"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_userxpstats_guildid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_userxpstats_userid"); + + b.HasIndex("Xp") + .HasDatabaseName("ix_userxpstats_xp"); + + b.HasIndex("UserId", "GuildId") + .IsUnique() + .HasDatabaseName("ix_userxpstats_userid_guildid"); + + b.ToTable("userxpstats", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.VcRoleInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("VoiceChannelId") + .HasColumnType("numeric(20,0)") + .HasColumnName("voicechannelid"); + + b.HasKey("Id") + .HasName("pk_vcroleinfo"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_vcroleinfo_guildconfigid"); + + b.ToTable("vcroleinfo", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffinityId") + .HasColumnType("integer") + .HasColumnName("affinityid"); + + b.Property("ClaimerId") + .HasColumnType("integer") + .HasColumnName("claimerid"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Price") + .HasColumnType("bigint") + .HasColumnName("price"); + + b.Property("WaifuId") + .HasColumnType("integer") + .HasColumnName("waifuid"); + + b.HasKey("Id") + .HasName("pk_waifuinfo"); + + b.HasIndex("AffinityId") + .HasDatabaseName("ix_waifuinfo_affinityid"); + + b.HasIndex("ClaimerId") + .HasDatabaseName("ix_waifuinfo_claimerid"); + + b.HasIndex("Price") + .HasDatabaseName("ix_waifuinfo_price"); + + b.HasIndex("WaifuId") + .IsUnique() + .HasDatabaseName("ix_waifuinfo_waifuid"); + + b.ToTable("waifuinfo", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("ItemEmoji") + .HasColumnType("text") + .HasColumnName("itememoji"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("WaifuInfoId") + .HasColumnType("integer") + .HasColumnName("waifuinfoid"); + + b.HasKey("Id") + .HasName("pk_waifuitem"); + + b.HasIndex("WaifuInfoId") + .HasDatabaseName("ix_waifuitem_waifuinfoid"); + + b.ToTable("waifuitem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("NewId") + .HasColumnType("integer") + .HasColumnName("newid"); + + b.Property("OldId") + .HasColumnType("integer") + .HasColumnName("oldid"); + + b.Property("UpdateType") + .HasColumnType("integer") + .HasColumnName("updatetype"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_waifuupdates"); + + b.HasIndex("NewId") + .HasDatabaseName("ix_waifuupdates_newid"); + + b.HasIndex("OldId") + .HasDatabaseName("ix_waifuupdates_oldid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_waifuupdates_userid"); + + b.ToTable("waifuupdates", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Forgiven") + .HasColumnType("boolean") + .HasColumnName("forgiven"); + + b.Property("ForgivenBy") + .HasColumnType("text") + .HasColumnName("forgivenby"); + + b.Property("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property("Moderator") + .HasColumnType("text") + .HasColumnName("moderator"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("weight"); + + b.HasKey("Id") + .HasName("pk_warnings"); + + b.HasIndex("DateAdded") + .HasDatabaseName("ix_warnings_dateadded"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_warnings_guildid"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_warnings_userid"); + + b.ToTable("warnings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WarningPunishment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("Punishment") + .HasColumnType("integer") + .HasColumnName("punishment"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("Time") + .HasColumnType("integer") + .HasColumnName("time"); + + b.HasKey("Id") + .HasName("pk_warningpunishment"); + + b.HasIndex("GuildConfigId") + .HasDatabaseName("ix_warningpunishment_guildconfigid"); + + b.ToTable("warningpunishment", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpCurrencyReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("XpSettingsId") + .HasColumnType("integer") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_xpcurrencyreward"); + + b.HasIndex("XpSettingsId") + .HasDatabaseName("ix_xpcurrencyreward_xpsettingsid"); + + b.ToTable("xpcurrencyreward", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("Remove") + .HasColumnType("boolean") + .HasColumnName("remove"); + + b.Property("RoleId") + .HasColumnType("numeric(20,0)") + .HasColumnName("roleid"); + + b.Property("XpSettingsId") + .HasColumnType("integer") + .HasColumnName("xpsettingsid"); + + b.HasKey("Id") + .HasName("pk_xprolereward"); + + b.HasIndex("XpSettingsId", "Level") + .IsUnique() + .HasDatabaseName("ix_xprolereward_xpsettingsid_level"); + + b.ToTable("xprolereward", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("GuildConfigId") + .HasColumnType("integer") + .HasColumnName("guildconfigid"); + + b.Property("ServerExcluded") + .HasColumnType("boolean") + .HasColumnName("serverexcluded"); + + b.HasKey("Id") + .HasName("pk_xpsettings"); + + b.HasIndex("GuildConfigId") + .IsUnique() + .HasDatabaseName("ix_xpsettings_guildconfigid"); + + b.ToTable("xpsettings", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpShopOwnedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone") + .HasColumnName("dateadded"); + + b.Property("IsUsing") + .HasColumnType("boolean") + .HasColumnName("isusing"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("itemkey"); + + b.Property("ItemType") + .HasColumnType("integer") + .HasColumnName("itemtype"); + + b.Property("UserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("userid"); + + b.HasKey("Id") + .HasName("pk_xpshopowneditem"); + + b.HasIndex("UserId", "ItemType", "ItemKey") + .IsUnique() + .HasDatabaseName("ix_xpshopowneditem_userid_itemtype_itemkey"); + + b.ToTable("xpshopowneditem", (string)null); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiAltSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiAltSetting") + .HasForeignKey("EllieBot.Db.Models.AntiAltSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antialtsetting_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiRaidSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiRaidSetting") + .HasForeignKey("EllieBot.Db.Models.AntiRaidSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antiraidsetting_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamIgnore", b => + { + b.HasOne("EllieBot.Db.Models.AntiSpamSetting", null) + .WithMany("IgnoredChannels") + .HasForeignKey("AntiSpamSettingId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_antispamignore_antispamsetting_antispamsettingid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiSpamSetting") + .HasForeignKey("EllieBot.Db.Models.AntiSpamSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_antispamsetting_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateUser", b => + { + b.HasOne("EllieBot.Db.Models.AutoTranslateChannel", "Channel") + .WithMany("Users") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_autotranslateusers_autotranslatechannels_channelid"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Applicants") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubapplicants_clubs_clubid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubapplicants_discorduser_userid"); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Bans") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubbans_clubs_clubid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clubbans_discorduser_userid"); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Owner") + .WithOne() + .HasForeignKey("EllieBot.Db.Models.ClubInfo", "OwnerId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_clubs_discorduser_ownerid"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandAlias", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("CommandAliases") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_commandalias_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandCooldown", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("CommandCooldowns") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_commandcooldown_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DelMsgOnCmdChannel", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("DelMsgOnCmdChannels") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_delmsgoncmdchannel_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Members") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction) + .HasConstraintName("fk_discorduser_clubs_clubid"); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("ExclusionList") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_excludeditem_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("FeedSubs") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_feedsub_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterInvitesChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_filterchannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterLinksChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterLinksChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_filterlinkschannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterWordsChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterWordsChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_filterwordschannelid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilteredWord", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilteredWords") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_filteredword_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FollowedStreams") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_followedstream_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GCChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("GenerateCurrencyChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_gcchannelid_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayUser", b => + { + b.HasOne("EllieBot.Db.Models.GiveawayModel", null) + .WithMany("Participants") + .HasForeignKey("GiveawayId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_giveawayuser_giveawaymodel_giveawayid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GroupName", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("SelfAssignableRoleGroupNames") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_groupname_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.IgnoredLogItem", b => + { + b.HasOne("EllieBot.Db.Models.LogSetting", "LogSetting") + .WithMany("LogIgnores") + .HasForeignKey("LogSettingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ignoredlogchannels_logsettings_logsettingid"); + + b.Navigation("LogSetting"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MutedUserId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("MutedUsers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_muteduserid_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Permissionv2", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("Permissions") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_permissions_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlaylistSong", b => + { + b.HasOne("EllieBot.Db.Models.MusicPlaylist", null) + .WithMany("Songs") + .HasForeignKey("MusicPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_playlistsong_musicplaylists_musicplaylistid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("ShopEntries") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shopentry_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntryItem", b => + { + b.HasOne("EllieBot.Db.Models.ShopEntry", null) + .WithMany("Items") + .HasForeignKey("ShopEntryId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shopentryitem_shopentry_shopentryid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredRole", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredRoles") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_slowmodeignoredrole_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredUser", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredUsers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_slowmodeignoreduser_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleBlacklistedUser", b => + { + b.HasOne("EllieBot.Db.Models.StreamRoleSettings", "StreamRoleSettings") + .WithMany("Blacklist") + .HasForeignKey("StreamRoleSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_streamroleblacklisteduser_streamrolesettings_streamrolesett~"); + + b.Navigation("StreamRoleSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithOne("StreamRole") + .HasForeignKey("EllieBot.Db.Models.StreamRoleSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_streamrolesettings_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleWhitelistedUser", b => + { + b.HasOne("EllieBot.Db.Models.StreamRoleSettings", "StreamRoleSettings") + .WithMany("Whitelist") + .HasForeignKey("StreamRoleSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_streamrolewhitelisteduser_streamrolesettings_streamrolesett~"); + + b.Navigation("StreamRoleSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.TodoModel", b => + { + b.HasOne("EllieBot.Db.Models.ArchivedTodoListModel", null) + .WithMany("Items") + .HasForeignKey("ArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_todos_todosarchive_archiveid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnbanTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnbanTimer") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_unbantimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnmuteTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnmuteTimers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_unmutetimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnroleTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnroleTimer") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_unroletimer_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.VcRoleInfo", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("VcRoleInfos") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_vcroleinfo_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Affinity") + .WithMany() + .HasForeignKey("AffinityId") + .HasConstraintName("fk_waifuinfo_discorduser_affinityid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Claimer") + .WithMany() + .HasForeignKey("ClaimerId") + .HasConstraintName("fk_waifuinfo_discorduser_claimerid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Waifu") + .WithOne() + .HasForeignKey("EllieBot.Db.Models.WaifuInfo", "WaifuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_waifuinfo_discorduser_waifuid"); + + b.Navigation("Affinity"); + + b.Navigation("Claimer"); + + b.Navigation("Waifu"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuItem", b => + { + b.HasOne("EllieBot.Db.Models.WaifuInfo", "WaifuInfo") + .WithMany("Items") + .HasForeignKey("WaifuInfoId") + .HasConstraintName("fk_waifuitem_waifuinfo_waifuinfoid"); + + b.Navigation("WaifuInfo"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuUpdate", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "New") + .WithMany() + .HasForeignKey("NewId") + .HasConstraintName("fk_waifuupdates_discorduser_newid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Old") + .WithMany() + .HasForeignKey("OldId") + .HasConstraintName("fk_waifuupdates_discorduser_oldid"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_waifuupdates_discorduser_userid"); + + b.Navigation("New"); + + b.Navigation("Old"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WarningPunishment", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("WarnPunishments") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_warningpunishment_guildconfigs_guildconfigid"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpCurrencyReward", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("CurrencyRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xpcurrencyreward_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("RoleRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xprolereward_xpsettings_xpsettingsid"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithOne("XpSettings") + .HasForeignKey("EllieBot.Db.Models.XpSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xpsettings_guildconfigs_guildconfigid"); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.Navigation("IgnoredChannels"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ArchivedTodoListModel", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateChannel", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Navigation("Applicants"); + + b.Navigation("Bans"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayModel", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GuildConfig", b => + { + b.Navigation("AntiAltSetting"); + + b.Navigation("AntiRaidSetting"); + + b.Navigation("AntiSpamSetting"); + + b.Navigation("CommandAliases"); + + b.Navigation("CommandCooldowns"); + + b.Navigation("DelMsgOnCmdChannels"); + + b.Navigation("FeedSubs"); + + b.Navigation("FilterInvitesChannelIds"); + + b.Navigation("FilterLinksChannelIds"); + + b.Navigation("FilterWordsChannelIds"); + + b.Navigation("FilteredWords"); + + b.Navigation("FollowedStreams"); + + b.Navigation("GenerateCurrencyChannelIds"); + + b.Navigation("MutedUsers"); + + b.Navigation("Permissions"); + + b.Navigation("SelfAssignableRoleGroupNames"); + + b.Navigation("ShopEntries"); + + b.Navigation("SlowmodeIgnoredRoles"); + + b.Navigation("SlowmodeIgnoredUsers"); + + b.Navigation("StreamRole"); + + b.Navigation("UnbanTimer"); + + b.Navigation("UnmuteTimers"); + + b.Navigation("UnroleTimer"); + + b.Navigation("VcRoleInfos"); + + b.Navigation("WarnPunishments"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b => + { + b.Navigation("LogIgnores"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlaylist", b => + { + b.Navigation("Songs"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.Navigation("Blacklist"); + + b.Navigation("Whitelist"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.Navigation("CurrencyRewards"); + + b.Navigation("ExclusionList"); + + b.Navigation("RoleRewards"); + }); +#pragma warning restore 612, 618 + } +} diff --git a/src/EllieBot/Migrations/Sqlite/EllieSqliteContextModelSnapshot.cs b/src/EllieBot/Migrations/Sqlite/EllieSqliteContextModelSnapshot.cs new file mode 100644 index 0000000..1d5dcf6 --- /dev/null +++ b/src/EllieBot/Migrations/Sqlite/EllieSqliteContextModelSnapshot.cs @@ -0,0 +1,2945 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using EllieBot.Db; + +#nullable disable + +namespace EllieBot.Migrations +{ + [DbContext(typeof(SqliteContext))] + partial class EllieSqliteContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + modelBuilder.Entity("EllieBot.Db.Models.AntiAltSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("ActionDurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("MinAge") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId") + .IsUnique(); + + b.ToTable("AntiAltSetting"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiRaidSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("PunishDuration") + .HasColumnType("INTEGER"); + + b.Property("Seconds") + .HasColumnType("INTEGER"); + + b.Property("UserThreshold") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId") + .IsUnique(); + + b.ToTable("AntiRaidSetting"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamIgnore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AntiSpamSettingId") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AntiSpamSettingId"); + + b.ToTable("AntiSpamIgnore"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("MessageThreshold") + .HasColumnType("INTEGER"); + + b.Property("MuteTime") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId") + .IsUnique(); + + b.ToTable("AntiSpamSetting"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ArchivedTodoListModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TodosArchive"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("ChannelName") + .HasColumnType("TEXT"); + + b.Property("CommandText") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("GuildName") + .HasColumnType("TEXT"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("VoiceChannelId") + .HasColumnType("INTEGER"); + + b.Property("VoiceChannelName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AutoCommands"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoPublishChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId") + .IsUnique(); + + b.ToTable("AutoPublishChannel"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoDelete") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("GuildId"); + + b.ToTable("AutoTranslateChannels"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Target") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasAlternateKey("ChannelId", "UserId"); + + b.ToTable("AutoTranslateUsers"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BanTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("PruneDays") + .HasColumnType("INTEGER"); + + b.Property("Text") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId") + .IsUnique(); + + b.ToTable("BanTemplates"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BankUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("BankUsers"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.BlacklistEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Blacklist"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("ClubId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("ClubApplicants"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("ClubId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("ClubBans"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("OwnerId") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Mapping") + .HasColumnType("TEXT"); + + b.Property("Trigger") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("CommandAlias"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandCooldown", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommandName") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Seconds") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("CommandCooldown"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CurrencyTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OtherId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValueSql("NULL"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("CurrencyTransactions"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DelMsgOnCmdChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("DelMsgOnCmdChannel"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordPermOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Perm") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "Command") + .IsUnique(); + + b.ToTable("DiscordPermOverrides"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarId") + .HasColumnType("TEXT"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyAmount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .HasColumnType("TEXT"); + + b.Property("IsClubAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("NotifyOnLevelUp") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("TotalXp") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasAlternateKey("UserId"); + + b.HasIndex("ClubId"); + + b.HasIndex("CurrencyAmount"); + + b.HasIndex("TotalXp"); + + b.HasIndex("UserId"); + + b.ToTable("DiscordUser"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("XpSettingsId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("XpSettingsId"); + + b.ToTable("ExcludedItem"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasAlternateKey("GuildConfigId", "Url"); + + b.ToTable("FeedSub"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("FilterChannelId"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterLinksChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("FilterLinksChannelId"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterWordsChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("FilterWordsChannelId"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilteredWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Word") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("FilteredWord"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("FollowedStream"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GCChannelId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("GCChannelId"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GamblingStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bet") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Feature") + .HasColumnType("TEXT"); + + b.Property("PaidOut") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Feature") + .IsUnique(); + + b.ToTable("GamblingStats"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("GiveawayModel"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GiveawayId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GiveawayId", "UserId") + .IsUnique(); + + b.ToTable("GiveawayUser"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GroupName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId", "Number") + .IsUnique(); + + b.ToTable("GroupName"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GuildConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoAssignRoleIds") + .HasColumnType("TEXT"); + + b.Property("AutoDeleteByeMessagesTimer") + .HasColumnType("INTEGER"); + + b.Property("AutoDeleteGreetMessagesTimer") + .HasColumnType("INTEGER"); + + b.Property("AutoDeleteSelfAssignedRoleMessages") + .HasColumnType("INTEGER"); + + b.Property("BoostMessage") + .HasColumnType("TEXT"); + + b.Property("BoostMessageChannelId") + .HasColumnType("INTEGER"); + + b.Property("BoostMessageDeleteAfter") + .HasColumnType("INTEGER"); + + b.Property("ByeMessageChannelId") + .HasColumnType("INTEGER"); + + b.Property("ChannelByeMessageText") + .HasColumnType("TEXT"); + + b.Property("ChannelGreetMessageText") + .HasColumnType("TEXT"); + + b.Property("CleverbotEnabled") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DeleteMessageOnCommand") + .HasColumnType("INTEGER"); + + b.Property("DeleteStreamOnlineMessage") + .HasColumnType("INTEGER"); + + b.Property("DisableGlobalExpressions") + .HasColumnType("INTEGER"); + + b.Property("DmGreetMessageText") + .HasColumnType("TEXT"); + + b.Property("ExclusiveSelfAssignedRoles") + .HasColumnType("INTEGER"); + + b.Property("FilterInvites") + .HasColumnType("INTEGER"); + + b.Property("FilterLinks") + .HasColumnType("INTEGER"); + + b.Property("FilterWords") + .HasColumnType("INTEGER"); + + b.Property("GameVoiceChannel") + .HasColumnType("INTEGER"); + + b.Property("GreetMessageChannelId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasColumnType("TEXT"); + + b.Property("MuteRoleName") + .HasColumnType("TEXT"); + + b.Property("NotifyStreamOffline") + .HasColumnType("INTEGER"); + + b.Property("PermissionRole") + .HasColumnType("TEXT"); + + b.Property("Prefix") + .HasColumnType("TEXT"); + + b.Property("SendBoostMessage") + .HasColumnType("INTEGER"); + + b.Property("SendChannelByeMessage") + .HasColumnType("INTEGER"); + + b.Property("SendChannelGreetMessage") + .HasColumnType("INTEGER"); + + b.Property("SendDmGreetMessage") + .HasColumnType("INTEGER"); + + b.Property("StickyRoles") + .HasColumnType("INTEGER"); + + b.Property("TimeZoneId") + .HasColumnType("TEXT"); + + b.Property("VerboseErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("VerbosePermissions") + .HasColumnType("INTEGER"); + + b.Property("WarnExpireAction") + .HasColumnType("INTEGER"); + + b.Property("WarnExpireHours") + .HasColumnType("INTEGER"); + + b.Property("WarningsInitialized") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId") + .IsUnique(); + + b.HasIndex("WarnExpireHours"); + + b.ToTable("GuildConfigs"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.IgnoredLogItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("LogItemId") + .HasColumnType("INTEGER"); + + b.Property("LogSettingId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LogSettingId", "LogItemId", "ItemType") + .IsUnique(); + + b.ToTable("IgnoredLogChannels"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ImageOnlyChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.ToTable("ImageOnlyChannels"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelCreatedId") + .HasColumnType("INTEGER"); + + b.Property("ChannelDestroyedId") + .HasColumnType("INTEGER"); + + b.Property("ChannelUpdatedId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LogOtherId") + .HasColumnType("INTEGER"); + + b.Property("LogUserPresenceId") + .HasColumnType("INTEGER"); + + b.Property("LogVoicePresenceId") + .HasColumnType("INTEGER"); + + b.Property("LogVoicePresenceTTSId") + .HasColumnType("INTEGER"); + + b.Property("LogWarnsId") + .HasColumnType("INTEGER"); + + b.Property("MessageDeletedId") + .HasColumnType("INTEGER"); + + b.Property("MessageUpdatedId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCreatedId") + .HasColumnType("INTEGER"); + + b.Property("ThreadDeletedId") + .HasColumnType("INTEGER"); + + b.Property("UserBannedId") + .HasColumnType("INTEGER"); + + b.Property("UserJoinedId") + .HasColumnType("INTEGER"); + + b.Property("UserLeftId") + .HasColumnType("INTEGER"); + + b.Property("UserMutedId") + .HasColumnType("INTEGER"); + + b.Property("UserUnbannedId") + .HasColumnType("INTEGER"); + + b.Property("UserUpdatedId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId") + .IsUnique(); + + b.ToTable("LogSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlayerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoDisconnect") + .HasColumnType("INTEGER"); + + b.Property("AutoPlay") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MusicChannelId") + .HasColumnType("INTEGER"); + + b.Property("PlayerRepeat") + .HasColumnType("INTEGER"); + + b.Property("QualityPreset") + .HasColumnType("INTEGER"); + + b.Property("Volume") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.HasKey("Id"); + + b.HasIndex("GuildId") + .IsUnique(); + + b.ToTable("MusicPlayerSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlaylist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MusicPlaylists"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MutedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("MutedUserId"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowTarget") + .HasColumnType("INTEGER"); + + b.Property("AutoDeleteTrigger") + .HasColumnType("INTEGER"); + + b.Property("ContainsAnywhere") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DmResponse") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Reactions") + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Trigger") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Expressions"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PatronQuota", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("FeatureType") + .HasColumnType("INTEGER"); + + b.Property("Feature") + .HasColumnType("TEXT"); + + b.Property("DailyCount") + .HasColumnType("INTEGER"); + + b.Property("HourlyCount") + .HasColumnType("INTEGER"); + + b.Property("MonthlyCount") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "FeatureType", "Feature"); + + b.HasIndex("UserId"); + + b.ToTable("PatronQuotas"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PatronUser", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AmountCents") + .HasColumnType("INTEGER"); + + b.Property("LastCharge") + .HasColumnType("TEXT"); + + b.Property("UniquePlatformUserId") + .HasColumnType("TEXT"); + + b.Property("ValidThru") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.HasIndex("UniquePlatformUserId") + .IsUnique(); + + b.ToTable("Patrons"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Permissionv2", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsCustomCommand") + .HasColumnType("INTEGER"); + + b.Property("PrimaryTarget") + .HasColumnType("INTEGER"); + + b.Property("PrimaryTargetId") + .HasColumnType("INTEGER"); + + b.Property("SecondaryTarget") + .HasColumnType("INTEGER"); + + b.Property("SecondaryTargetName") + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlantedCurrency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("PlantedCurrency"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlaylistSong", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("MusicPlaylistId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("TEXT"); + + b.Property("ProviderType") + .HasColumnType("INTEGER"); + + b.Property("Query") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MusicPlaylistId"); + + b.ToTable("PlaylistSong"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Quote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Keyword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("Keyword"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ReactionRoleV2", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Emote") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Group") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LevelReq") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("MessageId", "Emote") + .IsUnique(); + + b.ToTable("ReactionRoles"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Reminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IsPrivate") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("ServerId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("When") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("When"); + + b.ToTable("Reminders"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Repeater", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("TEXT"); + + b.Property("LastMessageId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("NoRedundant") + .HasColumnType("INTEGER"); + + b.Property("StartTimeOfDay") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Repeaters"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.RewardedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AmountRewardedThisMonth") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("LastReward") + .HasColumnType("TEXT"); + + b.Property("PlatformUserId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlatformUserId") + .IsUnique(); + + b.ToTable("RewardedUsers"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.RotatingPlayingStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("RotatingStatus"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SelfAssignedRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Group") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LevelRequirement") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RoleId") + .IsUnique(); + + b.ToTable("SelfAssignableRoles"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("RoleName") + .HasColumnType("TEXT"); + + b.Property("RoleRequirement") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("ShopEntry"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("ShopEntryId") + .HasColumnType("INTEGER"); + + b.Property("Text") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ShopEntryId"); + + b.ToTable("ShopEntryItem"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("SlowmodeIgnoredRole"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("SlowmodeIgnoredUser"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StickyRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RoleIds") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "UserId") + .IsUnique(); + + b.ToTable("StickyRoles"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamOnlineMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("StreamOnlineMessages"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleBlacklistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StreamRoleSettingsId"); + + b.ToTable("StreamRoleBlacklistedUser"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AddRoleId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FromRoleId") + .HasColumnType("INTEGER"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Keyword") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId") + .IsUnique(); + + b.ToTable("StreamRoleSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleWhitelistedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("StreamRoleSettingsId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StreamRoleSettingsId"); + + b.ToTable("StreamRoleWhitelistedUser"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.TodoModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArchiveId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IsDone") + .HasColumnType("INTEGER"); + + b.Property("Todo") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArchiveId"); + + b.HasIndex("UserId"); + + b.ToTable("Todos"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnbanTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("UnbanAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("UnbanTimer"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnmuteTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("UnmuteAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("UnmuteTimer"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnroleTimer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("UnbanAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("UnroleTimer"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AwardedXp") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("NotifyOnLevelUp") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AwardedXp"); + + b.HasIndex("GuildId"); + + b.HasIndex("UserId"); + + b.HasIndex("Xp"); + + b.HasIndex("UserId", "GuildId") + .IsUnique(); + + b.ToTable("UserXpStats"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.VcRoleInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("VoiceChannelId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("VcRoleInfo"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AffinityId") + .HasColumnType("INTEGER"); + + b.Property("ClaimerId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("WaifuId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AffinityId"); + + b.HasIndex("ClaimerId"); + + b.HasIndex("Price"); + + b.HasIndex("WaifuId") + .IsUnique(); + + b.ToTable("WaifuInfo"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("ItemEmoji") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("WaifuInfoId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WaifuInfoId"); + + b.ToTable("WaifuItem"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("NewId") + .HasColumnType("INTEGER"); + + b.Property("OldId") + .HasColumnType("INTEGER"); + + b.Property("UpdateType") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NewId"); + + b.HasIndex("OldId"); + + b.HasIndex("UserId"); + + b.ToTable("WaifuUpdates"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Forgiven") + .HasColumnType("INTEGER"); + + b.Property("ForgivenBy") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Moderator") + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1L); + + b.HasKey("Id"); + + b.HasIndex("DateAdded"); + + b.HasIndex("GuildId"); + + b.HasIndex("UserId"); + + b.ToTable("Warnings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WarningPunishment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("Punishment") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("Time") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId"); + + b.ToTable("WarningPunishment"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpCurrencyReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("XpSettingsId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("XpSettingsId"); + + b.ToTable("XpCurrencyReward"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Remove") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("XpSettingsId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("XpSettingsId", "Level") + .IsUnique(); + + b.ToTable("XpRoleReward"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("GuildConfigId") + .HasColumnType("INTEGER"); + + b.Property("ServerExcluded") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildConfigId") + .IsUnique(); + + b.ToTable("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpShopOwnedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IsUsing") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemType", "ItemKey") + .IsUnique(); + + b.ToTable("XpShopOwnedItem"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiAltSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiAltSetting") + .HasForeignKey("EllieBot.Db.Models.AntiAltSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiRaidSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiRaidSetting") + .HasForeignKey("EllieBot.Db.Models.AntiRaidSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamIgnore", b => + { + b.HasOne("EllieBot.Db.Models.AntiSpamSetting", null) + .WithMany("IgnoredChannels") + .HasForeignKey("AntiSpamSettingId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithOne("AntiSpamSetting") + .HasForeignKey("EllieBot.Db.Models.AntiSpamSetting", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateUser", b => + { + b.HasOne("EllieBot.Db.Models.AutoTranslateChannel", "Channel") + .WithMany("Users") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubApplicants", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Applicants") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubBans", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Bans") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Owner") + .WithOne() + .HasForeignKey("EllieBot.Db.Models.ClubInfo", "OwnerId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandAlias", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("CommandAliases") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.CommandCooldown", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("CommandCooldowns") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DelMsgOnCmdChannel", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("DelMsgOnCmdChannels") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EllieBot.Db.Models.DiscordUser", b => + { + b.HasOne("EllieBot.Db.Models.ClubInfo", "Club") + .WithMany("Members") + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("ExclusionList") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("FeedSubs") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterInvitesChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterLinksChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterLinksChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilterWordsChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilterWordsChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FilteredWord", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FilteredWords") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.FollowedStream", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("FollowedStreams") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GCChannelId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("GenerateCurrencyChannelIds") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayUser", b => + { + b.HasOne("EllieBot.Db.Models.GiveawayModel", null) + .WithMany("Participants") + .HasForeignKey("GiveawayId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GroupName", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithMany("SelfAssignableRoleGroupNames") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.IgnoredLogItem", b => + { + b.HasOne("EllieBot.Db.Models.LogSetting", "LogSetting") + .WithMany("LogIgnores") + .HasForeignKey("LogSettingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LogSetting"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MutedUserId", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("MutedUsers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.Permissionv2", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("Permissions") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.PlaylistSong", b => + { + b.HasOne("EllieBot.Db.Models.MusicPlaylist", null) + .WithMany("Songs") + .HasForeignKey("MusicPlaylistId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("ShopEntries") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntryItem", b => + { + b.HasOne("EllieBot.Db.Models.ShopEntry", null) + .WithMany("Items") + .HasForeignKey("ShopEntryId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredRole", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredRoles") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.SlowmodeIgnoredUser", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("SlowmodeIgnoredUsers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleBlacklistedUser", b => + { + b.HasOne("EllieBot.Db.Models.StreamRoleSettings", "StreamRoleSettings") + .WithMany("Blacklist") + .HasForeignKey("StreamRoleSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("StreamRoleSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithOne("StreamRole") + .HasForeignKey("EllieBot.Db.Models.StreamRoleSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleWhitelistedUser", b => + { + b.HasOne("EllieBot.Db.Models.StreamRoleSettings", "StreamRoleSettings") + .WithMany("Whitelist") + .HasForeignKey("StreamRoleSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("StreamRoleSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.TodoModel", b => + { + b.HasOne("EllieBot.Db.Models.ArchivedTodoListModel", null) + .WithMany("Items") + .HasForeignKey("ArchiveId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnbanTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnbanTimer") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnmuteTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnmuteTimers") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.UnroleTimer", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("UnroleTimer") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.VcRoleInfo", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("VcRoleInfos") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "Affinity") + .WithMany() + .HasForeignKey("AffinityId"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Claimer") + .WithMany() + .HasForeignKey("ClaimerId"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Waifu") + .WithOne() + .HasForeignKey("EllieBot.Db.Models.WaifuInfo", "WaifuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Affinity"); + + b.Navigation("Claimer"); + + b.Navigation("Waifu"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuItem", b => + { + b.HasOne("EllieBot.Db.Models.WaifuInfo", "WaifuInfo") + .WithMany("Items") + .HasForeignKey("WaifuInfoId"); + + b.Navigation("WaifuInfo"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuUpdate", b => + { + b.HasOne("EllieBot.Db.Models.DiscordUser", "New") + .WithMany() + .HasForeignKey("NewId"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "Old") + .WithMany() + .HasForeignKey("OldId"); + + b.HasOne("EllieBot.Db.Models.DiscordUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("New"); + + b.Navigation("Old"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WarningPunishment", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", null) + .WithMany("WarnPunishments") + .HasForeignKey("GuildConfigId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpCurrencyReward", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("CurrencyRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => + { + b.HasOne("EllieBot.Db.Models.XpSettings", "XpSettings") + .WithMany("RoleRewards") + .HasForeignKey("XpSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.HasOne("EllieBot.Db.Models.GuildConfig", "GuildConfig") + .WithOne("XpSettings") + .HasForeignKey("EllieBot.Db.Models.XpSettings", "GuildConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GuildConfig"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AntiSpamSetting", b => + { + b.Navigation("IgnoredChannels"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ArchivedTodoListModel", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.AutoTranslateChannel", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ClubInfo", b => + { + b.Navigation("Applicants"); + + b.Navigation("Bans"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GiveawayModel", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.GuildConfig", b => + { + b.Navigation("AntiAltSetting"); + + b.Navigation("AntiRaidSetting"); + + b.Navigation("AntiSpamSetting"); + + b.Navigation("CommandAliases"); + + b.Navigation("CommandCooldowns"); + + b.Navigation("DelMsgOnCmdChannels"); + + b.Navigation("FeedSubs"); + + b.Navigation("FilterInvitesChannelIds"); + + b.Navigation("FilterLinksChannelIds"); + + b.Navigation("FilterWordsChannelIds"); + + b.Navigation("FilteredWords"); + + b.Navigation("FollowedStreams"); + + b.Navigation("GenerateCurrencyChannelIds"); + + b.Navigation("MutedUsers"); + + b.Navigation("Permissions"); + + b.Navigation("SelfAssignableRoleGroupNames"); + + b.Navigation("ShopEntries"); + + b.Navigation("SlowmodeIgnoredRoles"); + + b.Navigation("SlowmodeIgnoredUsers"); + + b.Navigation("StreamRole"); + + b.Navigation("UnbanTimer"); + + b.Navigation("UnmuteTimers"); + + b.Navigation("UnroleTimer"); + + b.Navigation("VcRoleInfos"); + + b.Navigation("WarnPunishments"); + + b.Navigation("XpSettings"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b => + { + b.Navigation("LogIgnores"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.MusicPlaylist", b => + { + b.Navigation("Songs"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.ShopEntry", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.StreamRoleSettings", b => + { + b.Navigation("Blacklist"); + + b.Navigation("Whitelist"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.WaifuInfo", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EllieBot.Db.Models.XpSettings", b => + { + b.Navigation("CurrencyRewards"); + + b.Navigation("ExclusionList"); + + b.Navigation("RoleRewards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EllieBot/Modules/Administration/Administration.cs b/src/EllieBot/Modules/Administration/Administration.cs new file mode 100644 index 0000000..9e18e8b --- /dev/null +++ b/src/EllieBot/Modules/Administration/Administration.cs @@ -0,0 +1,499 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration._common.results; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration : EllieModule +{ + public enum Channel + { + Channel, + Ch, + Chnl, + Chan + } + + public enum List + { + List = 0, + Ls = 0 + } + + public enum Server + { + Server + } + + public enum State + { + Enable, + Disable, + Inherit + } + + private readonly SomethingOnlyChannelService _somethingOnly; + private readonly AutoPublishService _autoPubService; + + public Administration(SomethingOnlyChannelService somethingOnly, AutoPublishService autoPubService) + { + _somethingOnly = somethingOnly; + _autoPubService = autoPubService; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageGuild)] + public async Task ImageOnlyChannel(StoopidTime time = null) + { + var newValue = await _somethingOnly.ToggleImageOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id); + if (newValue) + await Response().Confirm(strs.imageonly_enable).SendAsync(); + else + await Response().Pending(strs.imageonly_disable).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageGuild)] + public async Task LinkOnlyChannel(StoopidTime time = null) + { + var newValue = await _somethingOnly.ToggleLinkOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id); + if (newValue) + await Response().Confirm(strs.linkonly_enable).SendAsync(); + else + await Response().Pending(strs.linkonly_disable).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageChannels)] + [BotPerm(ChannelPerm.ManageChannels)] + public async Task Slowmode(StoopidTime time = null) + { + var seconds = (int?)time?.Time.TotalSeconds ?? 0; + if (time is not null && (time.Time < TimeSpan.FromSeconds(0) || time.Time > TimeSpan.FromHours(6))) + return; + + await ((ITextChannel)ctx.Channel).ModifyAsync(tcp => + { + tcp.SlowModeInterval = seconds; + }); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(2)] + public async Task Delmsgoncmd(List _) + { + var guild = (SocketGuild)ctx.Guild; + var (enabled, channels) = _service.GetDelMsgOnCmdData(ctx.Guild.Id); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.server_delmsgoncmd)) + .WithDescription(enabled ? "✅" : "❌"); + + var str = string.Join("\n", + channels.Select(x => + { + var ch = guild.GetChannel(x.ChannelId)?.ToString() ?? x.ChannelId.ToString(); + var prefixSign = x.State ? "✅ " : "❌ "; + return prefixSign + ch; + })); + + if (string.IsNullOrWhiteSpace(str)) + str = "-"; + + embed.AddField(GetText(strs.channel_delmsgoncmd), str); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Delmsgoncmd(Server _ = Server.Server) + { + if (_service.ToggleDeleteMessageOnCommand(ctx.Guild.Id)) + { + _service.DeleteMessagesOnCommand.Add(ctx.Guild.Id); + await Response().Confirm(strs.delmsg_on).SendAsync(); + } + else + { + _service.DeleteMessagesOnCommand.TryRemove(ctx.Guild.Id); + await Response().Confirm(strs.delmsg_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Delmsgoncmd(Channel _, State s, ITextChannel ch) + => Delmsgoncmd(_, s, ch.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Delmsgoncmd(Channel _, State s, ulong? chId = null) + { + var actualChId = chId ?? ctx.Channel.Id; + await _service.SetDelMsgOnCmdState(ctx.Guild.Id, actualChId, s); + + if (s == State.Disable) + await Response().Confirm(strs.delmsg_channel_off).SendAsync(); + else if (s == State.Enable) + await Response().Confirm(strs.delmsg_channel_on).SendAsync(); + else + await Response().Confirm(strs.delmsg_channel_inherit).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.DeafenMembers)] + [BotPerm(GuildPerm.DeafenMembers)] + public async Task Deafen(params IGuildUser[] users) + { + await _service.DeafenUsers(true, users); + await Response().Confirm(strs.deafen).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.DeafenMembers)] + [BotPerm(GuildPerm.DeafenMembers)] + public async Task UnDeafen(params IGuildUser[] users) + { + await _service.DeafenUsers(false, users); + await Response().Confirm(strs.undeafen).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task DelVoiChanl([Leftover] IVoiceChannel voiceChannel) + { + await voiceChannel.DeleteAsync(); + await Response().Confirm(strs.delvoich(Format.Bold(voiceChannel.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task CreatVoiChanl([Leftover] string channelName) + { + var ch = await ctx.Guild.CreateVoiceChannelAsync(channelName); + await Response().Confirm(strs.createvoich(Format.Bold(ch.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task DelTxtChanl([Leftover] ITextChannel toDelete) + { + await toDelete.DeleteAsync(new RequestOptions() + { + AuditLogReason = $"Deleted by {ctx.User.Username}" + }); + await Response().Confirm(strs.deltextchan(Format.Bold(toDelete.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task CreaTxtChanl([Leftover] string channelName) + { + var txtCh = await ctx.Guild.CreateTextChannelAsync(channelName); + await Response().Confirm(strs.createtextchan(Format.Bold(txtCh.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task SetTopic([Leftover] string topic = null) + { + var channel = (ITextChannel)ctx.Channel; + topic ??= ""; + await channel.ModifyAsync(c => c.Topic = topic); + await Response().Confirm(strs.set_topic).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task SetChanlName([Leftover] string name) + { + var channel = (ITextChannel)ctx.Channel; + await channel.ModifyAsync(c => c.Name = name); + await Response().Confirm(strs.set_channel_name).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task AgeRestrictToggle() + { + var channel = (ITextChannel)ctx.Channel; + var isEnabled = channel.IsNsfw; + + await channel.ModifyAsync(c => c.IsNsfw = !isEnabled); + + if (isEnabled) + await Response().Confirm(strs.nsfw_set_false).SendAsync(); + else + await Response().Confirm(strs.nsfw_set_true).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [Priority(0)] + public Task Edit(ulong messageId, [Leftover] string text) + => Edit((ITextChannel)ctx.Channel, messageId, text); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task Edit(ITextChannel channel, ulong messageId, [Leftover] string text) + { + var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel); + var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel); + if (!userPerms.Has(ChannelPermission.ManageMessages)) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + if (!botPerms.Has(ChannelPermission.ViewChannel)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + await _service.EditMessage(ctx, channel, messageId, text); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + public Task Delete(ulong messageId, StoopidTime time = null) + => Delete((ITextChannel)ctx.Channel, messageId, time); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Delete(ITextChannel channel, ulong messageId, StoopidTime time = null) + => await InternalMessageAction(channel, messageId, time, msg => msg.DeleteAsync()); + + private async Task InternalMessageAction( + ITextChannel channel, + ulong messageId, + StoopidTime time, + Func func) + { + var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel); + var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel); + if (!userPerms.Has(ChannelPermission.ManageMessages)) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + if (!botPerms.Has(ChannelPermission.ManageMessages)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + + var msg = await channel.GetMessageAsync(messageId); + if (msg is null) + { + await Response().Error(strs.msg_not_found).SendAsync(); + return; + } + + if (time is null) + await msg.DeleteAsync(); + else if (time.Time <= TimeSpan.FromDays(7)) + { + _ = Task.Run(async () => + { + await Task.Delay(time.Time); + await msg.DeleteAsync(); + }); + } + else + { + await Response().Error(strs.time_too_long).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [BotPerm(ChannelPermission.CreatePublicThreads)] + [UserPerm(ChannelPermission.CreatePublicThreads)] + public async Task ThreadCreate([Leftover] string name) + { + if (ctx.Channel is not SocketTextChannel stc) + return; + + await stc.CreateThreadAsync(name, message: ctx.Message.ReferencedMessage); + await ctx.OkAsync(); + } + + [Cmd] + [BotPerm(ChannelPermission.ManageThreads)] + [UserPerm(ChannelPermission.ManageThreads)] + public async Task ThreadDelete([Leftover] string name) + { + if (ctx.Channel is not SocketTextChannel stc) + return; + + var t = stc.Threads.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.InvariantCultureIgnoreCase)); + + if (t is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + await t.DeleteAsync(); + await ctx.OkAsync(); + } + + [Cmd] + [UserPerm(ChannelPerm.ManageMessages)] + public async Task AutoPublish() + { + if (ctx.Channel.GetChannelType() != ChannelType.News) + { + await Response().Error(strs.req_announcement_channel).SendAsync(); + return; + } + + var newState = await _autoPubService.ToggleAutoPublish(ctx.Guild.Id, ctx.Channel.Id); + + if (newState) + { + await Response().Confirm(strs.autopublish_enable).SendAsync(); + } + else + { + await Response().Confirm(strs.autopublish_disable).SendAsync(); + } + } + + [Cmd] + [UserPerm(GuildPerm.ManageNicknames)] + [BotPerm(GuildPerm.ChangeNickname)] + [Priority(0)] + public async Task SetNick([Leftover] string newNick = null) + { + if (string.IsNullOrWhiteSpace(newNick)) + return; + var curUser = await ctx.Guild.GetCurrentUserAsync(); + await curUser.ModifyAsync(u => u.Nickname = newNick); + + await Response().Confirm(strs.bot_nick(Format.Bold(newNick) ?? "-")).SendAsync(); + } + + [Cmd] + [BotPerm(GuildPerm.ManageNicknames)] + [UserPerm(GuildPerm.ManageNicknames)] + [Priority(1)] + public async Task SetNick(IGuildUser gu, [Leftover] string newNick = null) + { + var sg = (SocketGuild)ctx.Guild; + if (sg.OwnerId == gu.Id + || gu.GetRoles().Max(r => r.Position) >= sg.CurrentUser.GetRoles().Max(r => r.Position)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + await gu.ModifyAsync(u => u.Nickname = newNick); + + await Response() + .Confirm(strs.user_nick(Format.Bold(gu.ToString()), Format.Bold(newNick) ?? "-")) + .SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPermission.ManageGuild)] + [BotPerm(GuildPermission.ManageGuild)] + public async Task SetServerBanner([Leftover] string img = null) + { + // Tier2 or higher is required to set a banner. + if (ctx.Guild.PremiumTier is PremiumTier.Tier1 or PremiumTier.None) return; + + var result = await _service.SetServerBannerAsync(ctx.Guild, img); + + switch (result) + { + case SetServerBannerResult.Success: + await Response().Confirm(strs.set_srvr_banner).SendAsync(); + break; + case SetServerBannerResult.InvalidFileType: + await Response().Error(strs.srvr_banner_invalid).SendAsync(); + break; + case SetServerBannerResult.Toolarge: + await Response().Error(strs.srvr_banner_too_large).SendAsync(); + break; + case SetServerBannerResult.InvalidURL: + await Response().Error(strs.srvr_banner_invalid_url).SendAsync(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPermission.ManageGuild)] + [BotPerm(GuildPermission.ManageGuild)] + public async Task SetServerIcon([Leftover] string img = null) + { + var result = await _service.SetServerIconAsync(ctx.Guild, img); + + switch (result) + { + case SetServerIconResult.Success: + await Response().Confirm(strs.set_srvr_icon).SendAsync(); + break; + case SetServerIconResult.InvalidFileType: + await Response().Error(strs.srvr_banner_invalid).SendAsync(); + break; + case SetServerIconResult.InvalidURL: + await Response().Error(strs.srvr_banner_invalid_url).SendAsync(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AdministrationService.cs b/src/EllieBot/Modules/Administration/AdministrationService.cs new file mode 100644 index 0000000..de037bd --- /dev/null +++ b/src/EllieBot/Modules/Administration/AdministrationService.cs @@ -0,0 +1,206 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Administration._common.results; + +namespace EllieBot.Modules.Administration.Services; + +public class AdministrationService : IEService +{ + public ConcurrentHashSet DeleteMessagesOnCommand { get; } + public ConcurrentDictionary DeleteMessagesOnCommandChannels { get; } + + private readonly DbService _db; + private readonly IReplacementService _repSvc; + private readonly ILogCommandService _logService; + private readonly IHttpClientFactory _httpFactory; + + public AdministrationService( + IBot bot, + CommandHandler cmdHandler, + DbService db, + IReplacementService repSvc, + ILogCommandService logService, + IHttpClientFactory factory) + { + _db = db; + _repSvc = repSvc; + _logService = logService; + _httpFactory = factory; + + DeleteMessagesOnCommand = new(bot.AllGuildConfigs.Where(g => g.DeleteMessageOnCommand).Select(g => g.GuildId)); + + DeleteMessagesOnCommandChannels = new(bot.AllGuildConfigs.SelectMany(x => x.DelMsgOnCmdChannels) + .ToDictionary(x => x.ChannelId, x => x.State) + .ToConcurrent()); + + cmdHandler.CommandExecuted += DelMsgOnCmd_Handler; + } + + public (bool DelMsgOnCmd, IEnumerable channels) GetDelMsgOnCmdData(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels)); + + return (conf.DeleteMessageOnCommand, conf.DelMsgOnCmdChannels); + } + + private Task DelMsgOnCmd_Handler(IUserMessage msg, CommandInfo cmd) + { + if (msg.Channel is not ITextChannel channel) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + //wat ?! + if (DeleteMessagesOnCommandChannels.TryGetValue(channel.Id, out var state)) + { + if (state && cmd.Name != "prune" && cmd.Name != "pick") + { + _logService.AddDeleteIgnore(msg.Id); + try { await msg.DeleteAsync(); } + catch { } + } + //if state is false, that means do not do it + } + else if (DeleteMessagesOnCommand.Contains(channel.Guild.Id) && cmd.Name != "prune" && cmd.Name != "pick") + { + _logService.AddDeleteIgnore(msg.Id); + try { await msg.DeleteAsync(); } + catch { } + } + }); + return Task.CompletedTask; + } + + public bool ToggleDeleteMessageOnCommand(ulong guildId) + { + bool enabled; + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + enabled = conf.DeleteMessageOnCommand = !conf.DeleteMessageOnCommand; + + uow.SaveChanges(); + return enabled; + } + + public async Task SetDelMsgOnCmdState(ulong guildId, ulong chId, Administration.State newState) + { + await using (var uow = _db.GetDbContext()) + { + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels)); + + var old = conf.DelMsgOnCmdChannels.FirstOrDefault(x => x.ChannelId == chId); + if (newState == Administration.State.Inherit) + { + if (old is not null) + { + conf.DelMsgOnCmdChannels.Remove(old); + uow.Remove(old); + } + } + else + { + if (old is null) + { + old = new() + { + ChannelId = chId + }; + conf.DelMsgOnCmdChannels.Add(old); + } + + old.State = newState == Administration.State.Enable; + DeleteMessagesOnCommandChannels[chId] = newState == Administration.State.Enable; + } + + await uow.SaveChangesAsync(); + } + + if (newState == Administration.State.Disable) + { + } + else if (newState == Administration.State.Enable) + DeleteMessagesOnCommandChannels[chId] = true; + else + DeleteMessagesOnCommandChannels.TryRemove(chId, out _); + } + + public async Task DeafenUsers(bool value, params IGuildUser[] users) + { + if (!users.Any()) + return; + foreach (var u in users) + { + try + { + await u.ModifyAsync(usr => usr.Deaf = value); + } + catch + { + // ignored + } + } + } + + public async Task EditMessage( + ICommandContext context, + ITextChannel chanl, + ulong messageId, + string input) + { + var msg = await chanl.GetMessageAsync(messageId); + + if (msg is not IUserMessage umsg || msg.Author.Id != context.Client.CurrentUser.Id) + return; + + var repCtx = new ReplacementContext(context); + + var text = SmartText.CreateFrom(input); + text = await _repSvc.ReplaceAsync(text, repCtx); + + await umsg.EditAsync(text); + } + + public async Task SetServerBannerAsync(IGuild guild, string img) + { + if (!IsValidUri(img)) return SetServerBannerResult.InvalidURL; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + + if (!sr.IsImage()) return SetServerBannerResult.InvalidFileType; + + if (sr.GetContentLength() > 8.Megabytes()) + { + return SetServerBannerResult.Toolarge; + } + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await guild.ModifyAsync(x => x.Banner = new Image(imageStream)); + return SetServerBannerResult.Success; + } + + public async Task SetServerIconAsync(IGuild guild, string img) + { + if (!IsValidUri(img)) return SetServerIconResult.InvalidURL; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + + if (!sr.IsImage()) return SetServerIconResult.InvalidFileType; + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await guild.ModifyAsync(x => x.Icon = new Image(imageStream)); + return SetServerIconResult.Success; + } + + private bool IsValidUri(string img) => !string.IsNullOrWhiteSpace(img) && Uri.IsWellFormedUriString(img, UriKind.Absolute); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs new file mode 100644 index 0000000..dc687cc --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs @@ -0,0 +1,60 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class AutoAssignRoleCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task AutoAssignRole([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (role.Id == ctx.Guild.EveryoneRole.Id) + return; + + // the user can't aar the role which is higher or equal to his highest role + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + { + await Response().Error(strs.hierarchy).SendAsync(); + return; + } + + var roles = await _service.ToggleAarAsync(ctx.Guild.Id, role.Id); + if (roles.Count == 0) + await Response().Confirm(strs.aar_disabled).SendAsync(); + else if (roles.Contains(role.Id)) + await AutoAssignRole(); + else + await Response().Confirm(strs.aar_role_removed(Format.Bold(role.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task AutoAssignRole() + { + if (!_service.TryGetRoles(ctx.Guild.Id, out var roles)) + { + await Response().Confirm(strs.aar_none).SendAsync(); + return; + } + + var existing = roles.Select(rid => ctx.Guild.GetRole(rid)).Where(r => r is not null).ToList(); + + if (existing.Count != roles.Count) + await _service.SetAarRolesAsync(ctx.Guild.Id, existing.Select(x => x.Id)); + + await Response() + .Confirm(strs.aar_roles( + '\n' + existing.Select(x => Format.Bold(x.ToString())).Join(",\n"))) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs new file mode 100644 index 0000000..f373d45 --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs @@ -0,0 +1,159 @@ +#nullable disable +using EllieBot.Db.Models; +using System.Net; +using System.Threading.Channels; +using LinqToDB; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class AutoAssignRoleService : IEService +{ + private readonly DiscordSocketClient _client; + private readonly DbService _db; + + //guildid/roleid + private readonly ConcurrentDictionary> _autoAssignableRoles; + + private readonly Channel _assignQueue = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + public AutoAssignRoleService(DiscordSocketClient client, IBot bot, DbService db) + { + _client = client; + _db = db; + + _autoAssignableRoles = bot.AllGuildConfigs.Where(x => !string.IsNullOrWhiteSpace(x.AutoAssignRoleIds)) + .ToDictionary>(k => k.GuildId, + v => v.GetAutoAssignableRoles()) + .ToConcurrent(); + + _ = Task.Run(async () => + { + while (true) + { + var user = await _assignQueue.Reader.ReadAsync(); + if (!_autoAssignableRoles.TryGetValue(user.Guild.Id, out var savedRoleIds)) + continue; + + try + { + var roleIds = savedRoleIds.Select(roleId => user.Guild.GetRole(roleId)) + .Where(x => x is not null) + .ToList(); + + if (roleIds.Any()) + { + await user.AddRolesAsync(roleIds); + await Task.Delay(250); + } + else + { + Log.Warning( + "Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server the roles dont exist", + user.Guild.Name, + user.Guild.Id); + + await DisableAarAsync(user.Guild.Id); + } + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + Log.Warning( + "Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server because I don't have role management permissions", + user.Guild.Name, + user.Guild.Id); + + await DisableAarAsync(user.Guild.Id); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in aar. Probably one of the roles doesn't exist"); + } + } + }); + + _client.UserJoined += OnClientOnUserJoined; + _client.RoleDeleted += OnClientRoleDeleted; + } + + private async Task OnClientRoleDeleted(SocketRole role) + { + if (_autoAssignableRoles.TryGetValue(role.Guild.Id, out var roles) && roles.Contains(role.Id)) + await ToggleAarAsync(role.Guild.Id, role.Id); + } + + private async Task OnClientOnUserJoined(SocketGuildUser user) + { + if (_autoAssignableRoles.TryGetValue(user.Guild.Id, out _)) + await _assignQueue.Writer.WriteAsync(user); + } + + public async Task> ToggleAarAsync(ulong guildId, ulong roleId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var roles = gc.GetAutoAssignableRoles(); + if (!roles.Remove(roleId) && roles.Count < 3) + roles.Add(roleId); + + gc.SetAutoAssignableRoles(roles); + await uow.SaveChangesAsync(); + + if (roles.Count > 0) + _autoAssignableRoles[guildId] = roles; + else + _autoAssignableRoles.TryRemove(guildId, out _); + + return roles; + } + + public async Task DisableAarAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + + await uow.Set().AsNoTracking() + .Where(x => x.GuildId == guildId) + .UpdateAsync(_ => new() + { + AutoAssignRoleIds = null + }); + + _autoAssignableRoles.TryRemove(guildId, out _); + + await uow.SaveChangesAsync(); + } + + public async Task SetAarRolesAsync(ulong guildId, IEnumerable newRoles) + { + await using var uow = _db.GetDbContext(); + + var gc = uow.GuildConfigsForId(guildId, set => set); + gc.SetAutoAssignableRoles(newRoles); + + await uow.SaveChangesAsync(); + } + + public bool TryGetRoles(ulong guildId, out IReadOnlyList roles) + => _autoAssignableRoles.TryGetValue(guildId, out roles); +} + +public static class GuildConfigExtensions +{ + public static List GetAutoAssignableRoles(this GuildConfig gc) + { + if (string.IsNullOrWhiteSpace(gc.AutoAssignRoleIds)) + return new(); + + return gc.AutoAssignRoleIds.Split(',').Select(ulong.Parse).ToList(); + } + + public static void SetAutoAssignableRoles(this GuildConfig gc, IEnumerable roles) + => gc.AutoAssignRoleIds = roles.Join(','); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AutoPublishService.cs b/src/EllieBot/Modules/Administration/AutoPublishService.cs new file mode 100644 index 0000000..8f29495 --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoPublishService.cs @@ -0,0 +1,87 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public class AutoPublishService : IExecNoCommand, IReadyExecutor, IEService +{ + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCredsProvider _creds; + private ConcurrentDictionary _enabled; + + public AutoPublishService(DbService db, DiscordSocketClient client, IBotCredsProvider creds) + { + _db = db; + _client = client; + _creds = creds; + } + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (guild is null) + return; + + if (msg.Channel.GetChannelType() != ChannelType.News) + return; + + if (!_enabled.TryGetValue(guild.Id, out var cid) || cid != msg.Channel.Id) + return; + + await msg.CrosspostAsync(new RequestOptions() + { + RetryMode = RetryMode.AlwaysFail + }); + } + + public async Task OnReadyAsync() + { + var creds = _creds.GetCreds(); + + await using var ctx = _db.GetDbContext(); + var items = await ctx.GetTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, creds.TotalShards, _client.ShardId)) + .ToListAsyncLinqToDB(); + + _enabled = items + .ToDictionary(x => x.GuildId, x => x.ChannelId) + .ToConcurrent(); + } + + public async Task ToggleAutoPublish(ulong guildId, ulong channelId) + { + await using var ctx = _db.GetDbContext(); + var deleted = await ctx.GetTable() + .DeleteAsync(x => x.GuildId == guildId && x.ChannelId == channelId); + + if (deleted != 0) + { + _enabled.TryRemove(guildId, out _); + return false; + } + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }, + old => new() + { + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }, + () => new() + { + GuildId = guildId + }); + + _enabled[guildId] = channelId; + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs new file mode 100644 index 0000000..8fee8a3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs @@ -0,0 +1,31 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public partial class Administration +{ + [Group] + public class CleanupCommands : CleanupModuleBase + { + private readonly ICleanupService _svc; + + public CleanupCommands(ICleanupService svc) + => _svc = svc; + + [Cmd] + [OwnerOnly] + [RequireContext(ContextType.DM)] + public async Task CleanupGuildData() + { + var result = await _svc.DeleteMissingGuildDataAsync(); + + if (result is null) + { + await ctx.ErrorAsync(); + return; + } + + await Response() + .Confirm($"{result.GuildCount} guilds' data remain in the database.") + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs new file mode 100644 index 0000000..3555a61 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs @@ -0,0 +1,106 @@ +using LinqToDB; +using LinqToDB.Data; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class CleanupService : ICleanupService, IReadyExecutor, IEService +{ + private readonly IPubSub _pubSub; + private TypedKey _keepReportKey = new("cleanup:report"); + private TypedKey _keepTriggerKey = new("cleanup:trigger"); + private readonly DiscordSocketClient _client; + private ConcurrentDictionary guildIds = new(); + private readonly IBotCredsProvider _creds; + private readonly DbService _db; + + public CleanupService( + IPubSub pubSub, + DiscordSocketClient client, + IBotCredsProvider creds, + DbService db) + { + _pubSub = pubSub; + _client = client; + _creds = creds; + _db = db; + } + + public async Task DeleteMissingGuildDataAsync() + { + guildIds = new(); + var totalShards = _creds.GetCreds().TotalShards; + await _pubSub.Pub(_keepTriggerKey, true); + var counter = 0; + while (guildIds.Keys.Count < totalShards) + { + await Task.Delay(1000); + counter++; + + if (counter >= 5) + break; + } + + if (guildIds.Keys.Count < totalShards) + return default; + + var allIds = guildIds.SelectMany(x => x.Value) + .ToArray(); + + await using var ctx = _db.GetDbContext(); + await using var linqCtx = ctx.CreateLinqToDBContext(); + await using var tempTable = linqCtx.CreateTempTable(); + + foreach (var chunk in allIds.Chunk(20000)) + { + await tempTable.BulkCopyAsync(chunk.Select(x => new CleanupId() + { + GuildId = x + })); + } + + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + return new() + { + GuildCount = guildIds.Keys.Count, + }; + } + + private ValueTask OnKeepReport(KeepReport report) + { + guildIds[report.ShardId] = report.GuildIds; + return default; + } + + public async Task OnReadyAsync() + { + await _pubSub.Sub(_keepTriggerKey, OnKeepTrigger); + + if (_client.ShardId == 0) + await _pubSub.Sub(_keepReportKey, OnKeepReport); + } + + private ValueTask OnKeepTrigger(bool arg) + { + _pubSub.Pub(_keepReportKey, + new KeepReport() + { + ShardId = _client.ShardId, + GuildIds = _client.GetGuildIds(), + }); + + return default; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs new file mode 100644 index 0000000..80484c3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs @@ -0,0 +1,164 @@ +#nullable disable +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; +using EllieBot.Modules.Gambling; +using EllieBot.Modules.Administration.Services; +using EllieBot.Modules.Xp; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + [OwnerOnly] + [NoPublicBot] + public partial class DangerousCommands : CleanupModuleBase + { + private readonly DangerousCommandsService _ds; + private readonly IGamblingCleanupService _gcs; + private readonly IXpCleanupService _xcs; + + public DangerousCommands( + DangerousCommandsService ds, + IGamblingCleanupService gcs, + IXpCleanupService xcs) + { + _ds = ds; + _gcs = gcs; + _xcs = xcs; + } + + [Cmd] + [OwnerOnly] + public Task SqlSelect([Leftover] string sql) + { + var result = _ds.SelectSql(sql); + + return Response() + .Paginated() + .Items(result.Results) + .PageSize(20) + .Page((items, _) => + { + if (!items.Any()) + return _sender.CreateEmbed().WithErrorColor().WithFooter(sql).WithDescription("-"); + + return _sender.CreateEmbed() + .WithOkColor() + .WithFooter(sql) + .WithTitle(string.Join(" ║ ", result.ColumnNames)) + .WithDescription(string.Join('\n', items.Select(x => string.Join(" ║ ", x)))); + }) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SqlSelectCsv([Leftover] string sql) + { + var result = _ds.SelectSql(sql); + + // create a file stream and write the data as csv + using var ms = new MemoryStream(); + await using var sw = new StreamWriter(ms); + await using var csv = new CsvWriter(sw, + new CsvConfiguration(CultureInfo.InvariantCulture) + { + Delimiter = "," + }); + + foreach (var cn in result.ColumnNames) + { + csv.WriteField(cn); + } + + await csv.NextRecordAsync(); + + foreach (var row in result.Results) + { + foreach (var field in row) + { + csv.WriteField(field); + } + + await csv.NextRecordAsync(); + } + + + await csv.FlushAsync(); + ms.Position = 0; + + // send the file + await ctx.Channel.SendFileAsync(ms, $"query_result_{DateTime.UtcNow.Ticks}.csv"); + } + + [Cmd] + [OwnerOnly] + public async Task SqlExec([Leftover] string sql) + { + try + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.sql_confirm_exec)) + .WithDescription(Format.Code(sql)); + + if (!await PromptUserConfirmAsync(embed)) + return; + + var res = await _ds.ExecuteSql(sql); + await Response().Confirm(res.ToString()).SendAsync(); + } + catch (Exception ex) + { + await Response().Error(ex.ToString()).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task PurgeUser(ulong userId) + { + var embed = _sender.CreateEmbed() + .WithDescription(GetText(strs.purge_user_confirm(Format.Bold(userId.ToString())))); + + if (!await PromptUserConfirmAsync(embed)) + return; + + await _ds.PurgeUserAsync(userId); + await ctx.OkAsync(); + } + + [Cmd] + [OwnerOnly] + public Task PurgeUser([Leftover] IUser user) + => PurgeUser(user.Id); + + [Cmd] + [OwnerOnly] + public Task DeleteXp() + => ConfirmActionInternalAsync("Delete Xp", () => _xcs.DeleteXp()); + + + [Cmd] + [OwnerOnly] + public Task DeleteWaifus() + => ConfirmActionInternalAsync("Delete Waifus", () => _gcs.DeleteWaifus()); + + [Cmd] + [OwnerOnly] + public async Task DeleteWaifu(IUser user) + => await DeleteWaifu(user.Id); + + [Cmd] + [OwnerOnly] + public Task DeleteWaifu(ulong userId) + => ConfirmActionInternalAsync($"Delete Waifu {userId}", () => _gcs.DeleteWaifu(userId)); + + + [Cmd] + [OwnerOnly] + public Task DeleteCurrency() + => ConfirmActionInternalAsync("Delete Currency", () => _gcs.DeleteCurrency()); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs new file mode 100644 index 0000000..bbea48b --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs @@ -0,0 +1,103 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public class DangerousCommandsService : IEService +{ + private readonly DbService _db; + + public DangerousCommandsService(DbService db) + => _db = db; + + public async Task ExecuteSql(string sql) + { + int res; + await using var uow = _db.GetDbContext(); + res = await uow.Database.ExecuteSqlRawAsync(sql); + return res; + } + + public SelectResult SelectSql(string sql) + { + var result = new SelectResult + { + ColumnNames = new(), + Results = new() + }; + + using var uow = _db.GetDbContext(); + var conn = uow.Database.GetDbConnection(); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + using var reader = cmd.ExecuteReader(); + if (reader.HasRows) + { + for (var i = 0; i < reader.FieldCount; i++) + result.ColumnNames.Add(reader.GetName(i)); + while (reader.Read()) + { + var obj = new object[reader.FieldCount]; + reader.GetValues(obj); + result.Results.Add(obj.Select(x => x.ToString()).ToArray()); + } + } + + return result; + } + + public async Task PurgeUserAsync(ulong userId) + { + await using var uow = _db.GetDbContext(); + + // get waifu info + var wi = await uow.Set().FirstOrDefaultAsyncEF(x => x.Waifu.UserId == userId); + + // if it exists, delete waifu related things + if (wi is not null) + { + // remove updates which have new or old as this waifu + await uow.Set().DeleteAsync(wu => wu.New.UserId == userId || wu.Old.UserId == userId); + + // delete all items this waifu owns + await uow.Set().DeleteAsync(x => x.WaifuInfoId == wi.Id); + + // all waifus this waifu claims are released + await uow.Set() + .AsQueryable() + .Where(x => x.Claimer.UserId == userId) + .UpdateAsync(x => new() + { + ClaimerId = null + }); + + // all affinities set to this waifu are reset + await uow.Set() + .AsQueryable() + .Where(x => x.Affinity.UserId == userId) + .UpdateAsync(x => new() + { + AffinityId = null + }); + } + + // delete guild xp + await uow.Set().DeleteAsync(x => x.UserId == userId); + + // delete currency transactions + await uow.Set().DeleteAsync(x => x.UserId == userId); + + // delete user, currency, and clubs go away with it + await uow.Set().DeleteAsync(u => u.UserId == userId); + } + + public class SelectResult + { + public List ColumnNames { get; set; } + public List Results { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs new file mode 100644 index 0000000..cd6d742 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class CleanupId +{ + [Key] + public ulong GuildId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs new file mode 100644 index 0000000..a396082 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public interface ICleanupService +{ + Task DeleteMissingGuildDataAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs new file mode 100644 index 0000000..44ecee2 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class KeepReport +{ + public required int ShardId { get; init; } + public required ulong[] GuildIds { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs new file mode 100644 index 0000000..52c8051 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class KeepResult +{ + public required int GuildCount { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs new file mode 100644 index 0000000..8099573 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs @@ -0,0 +1,36 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class GameVoiceChannelCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.MoveMembers)] + public async Task GameVoiceChannel() + { + var vch = ((IGuildUser)ctx.User).VoiceChannel; + + if (vch is null) + { + await Response().Error(strs.not_in_voice).SendAsync(); + return; + } + + var id = _service.ToggleGameVoiceChannel(ctx.Guild.Id, vch.Id); + + if (id is null) + await Response().Confirm(strs.gvc_disabled).SendAsync(); + else + { + _service.GameVoiceChannels.Add(vch.Id); + await Response().Confirm(strs.gvc_enabled(Format.Bold(vch.Name))).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs new file mode 100644 index 0000000..54f9870 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs @@ -0,0 +1,127 @@ +#nullable disable +using EllieBot.Db; + +namespace EllieBot.Modules.Administration.Services; + +public class GameVoiceChannelService : IEService +{ + public ConcurrentHashSet GameVoiceChannels { get; } + + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public GameVoiceChannelService(DiscordSocketClient client, DbService db, IBot bot) + { + _db = db; + _client = client; + + GameVoiceChannels = new(bot.AllGuildConfigs + .Where(gc => gc.GameVoiceChannel is not null) + .Select(gc => gc.GameVoiceChannel!.Value)); + + _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated; + _client.PresenceUpdated += OnPresenceUpdate; + } + + private Task OnPresenceUpdate(SocketUser socketUser, SocketPresence before, SocketPresence after) + { + _ = Task.Run(async () => + { + try + { + if (socketUser is not SocketGuildUser newUser) + return; + // if the user is in the voice channel and that voice channel is gvc + + if (newUser.VoiceChannel is not { } vc + || !GameVoiceChannels.Contains(vc.Id)) + return; + + //if the activity has changed, and is a playi1ng activity + foreach (var activity in after.Activities) + { + if (activity is { Type: ActivityType.Playing }) + //trigger gvc + { + if (await TriggerGvc(newUser, activity.Name)) + return; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error running GuildMemberUpdated in gvc"); + } + }); + return Task.CompletedTask; + } + + public ulong? ToggleGameVoiceChannel(ulong guildId, ulong vchId) + { + ulong? id; + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + + if (gc.GameVoiceChannel == vchId) + { + GameVoiceChannels.TryRemove(vchId); + id = gc.GameVoiceChannel = null; + } + else + { + if (gc.GameVoiceChannel is not null) + GameVoiceChannels.TryRemove(gc.GameVoiceChannel.Value); + GameVoiceChannels.Add(vchId); + id = gc.GameVoiceChannel = vchId; + } + + uow.SaveChanges(); + return id; + } + + private Task OnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState) + { + _ = Task.Run(async () => + { + try + { + if (usr is not SocketGuildUser gUser) + return; + + if (newState.VoiceChannel is null) + return; + + if (!GameVoiceChannels.Contains(newState.VoiceChannel.Id)) + return; + + foreach (var game in gUser.Activities.Select(x => x.Name)) + { + if (await TriggerGvc(gUser, game)) + return; + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error running VoiceStateUpdate in gvc"); + } + }); + + return Task.CompletedTask; + } + + private async Task TriggerGvc(SocketGuildUser gUser, string game) + { + if (string.IsNullOrWhiteSpace(game)) + return false; + + game = game.TrimTo(50)!.ToLowerInvariant(); + var vch = gUser.Guild.VoiceChannels.FirstOrDefault(x => x.Name.ToLowerInvariant() == game); + + if (vch is null) + return false; + + await Task.Delay(1000); + await gUser.ModifyAsync(gu => gu.Channel = vch); + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs new file mode 100644 index 0000000..53dd058 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs @@ -0,0 +1,229 @@ +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class GreetCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Boost() + { + var enabled = await _service.ToggleBoost(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.boost_on).SendAsync(); + else + await Response().Pending(strs.boost_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task BoostDel(int timer = 30) + { + if (timer is < 0 or > 600) + return; + + await _service.SetBoostDel(ctx.Guild.Id, timer); + + if (timer > 0) + await Response().Confirm(strs.boostdel_on(timer)).SendAsync(); + else + await Response().Pending(strs.boostdel_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task BoostMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var boostMessage = _service.GetBoostMessage(ctx.Guild.Id); + await Response().Confirm(strs.boostmsg_cur(boostMessage?.SanitizeMentions())).SendAsync(); + return; + } + + var sendBoostEnabled = _service.SetBoostMessage(ctx.Guild.Id, ref text); + + await Response().Confirm(strs.boostmsg_new).SendAsync(); + if (!sendBoostEnabled) + await Response().Pending(strs.boostmsg_enable($"`{prefix}boost`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDel(int timer = 30) + { + if (timer is < 0 or > 600) + return; + + await _service.SetGreetDel(ctx.Guild.Id, timer); + + if (timer > 0) + await Response().Confirm(strs.greetdel_on(timer)).SendAsync(); + else + await Response().Pending(strs.greetdel_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Greet() + { + var enabled = await _service.SetGreet(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.greet_on).SendAsync(); + else + await Response().Pending(strs.greet_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var greetMsg = _service.GetGreetMsg(ctx.Guild.Id); + await Response().Confirm(strs.greetmsg_cur(greetMsg?.SanitizeMentions())).SendAsync(); + return; + } + + var sendGreetEnabled = _service.SetGreetMessage(ctx.Guild.Id, ref text); + + await Response().Confirm(strs.greetmsg_new).SendAsync(); + + if (!sendGreetEnabled) + await Response().Pending(strs.greetmsg_enable($"`{prefix}greet`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDm() + { + var enabled = await _service.SetGreetDm(ctx.Guild.Id); + + if (enabled) + await Response().Confirm(strs.greetdm_on).SendAsync(); + else + await Response().Confirm(strs.greetdm_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDmMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var dmGreetMsg = _service.GetDmGreetMsg(ctx.Guild.Id); + await Response().Confirm(strs.greetdmmsg_cur(dmGreetMsg?.SanitizeMentions())).SendAsync(); + return; + } + + var sendGreetEnabled = _service.SetGreetDmMessage(ctx.Guild.Id, ref text); + + await Response().Confirm(strs.greetdmmsg_new).SendAsync(); + if (!sendGreetEnabled) + await Response().Pending(strs.greetdmmsg_enable($"`{prefix}greetdm`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Bye() + { + var enabled = await _service.SetBye(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.bye_on).SendAsync(); + else + await Response().Confirm(strs.bye_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task ByeMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var byeMsg = _service.GetByeMessage(ctx.Guild.Id); + await Response().Confirm(strs.byemsg_cur(byeMsg?.SanitizeMentions())).SendAsync(); + return; + } + + var sendByeEnabled = _service.SetByeMessage(ctx.Guild.Id, ref text); + + await Response().Confirm(strs.byemsg_new).SendAsync(); + if (!sendByeEnabled) + await Response().Pending(strs.byemsg_enable($"`{prefix}bye`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task ByeDel(int timer = 30) + { + await _service.SetByeDel(ctx.Guild.Id, timer); + + if (timer > 0) + await Response().Confirm(strs.byedel_on(timer)).SendAsync(); + else + await Response().Pending(strs.byedel_off).SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task ByeTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + await _service.ByeTest((ITextChannel)ctx.Channel, user); + var enabled = _service.GetByeEnabled(ctx.Guild.Id); + if (!enabled) + await Response().Pending(strs.byemsg_enable($"`{prefix}bye`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task GreetTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + await _service.GreetTest((ITextChannel)ctx.Channel, user); + var enabled = _service.GetGreetEnabled(ctx.Guild.Id); + if (!enabled) + await Response().Pending(strs.greetmsg_enable($"`{prefix}greet`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task GreetDmTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + var success = await _service.GreetDmTest(user); + if (success) + await ctx.OkAsync(); + else + await ctx.WarningAsync(); + var enabled = _service.GetGreetDmEnabled(ctx.Guild.Id); + if (!enabled) + await Response().Pending(strs.greetdmmsg_enable($"`{prefix}greetdm`")).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetGrouper.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetGrouper.cs new file mode 100644 index 0000000..a92256e --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetGrouper.cs @@ -0,0 +1,71 @@ +namespace EllieBot.Services; + +public class GreetGrouper +{ + private readonly Dictionary> _group; + private readonly object _locker = new(); + + public GreetGrouper() + => _group = new(); + + + /// + /// Creates a group, if group already exists, adds the specified user + /// + /// Id of the server for which to create group for + /// User to add if group already exists + /// + public bool CreateOrAdd(ulong guildId, T toAddIfExists) + { + lock (_locker) + { + if (_group.TryGetValue(guildId, out var list)) + { + list.Add(toAddIfExists); + return false; + } + + _group[guildId] = new(); + return true; + } + } + + /// + /// Remove the specified amount of items from the group. If all items are removed, group will be removed. + /// + /// Id of the group + /// Maximum number of items to retrieve + /// Items retrieved + /// Whether the group has no more items left and is deleted + public bool ClearGroup(ulong guildId, int count, out IReadOnlyCollection items) + { + lock (_locker) + { + if (_group.TryGetValue(guildId, out var set)) + { + // if we want more than there are, return everything + if (count >= set.Count) + { + items = set; + _group.Remove(guildId); + return true; + } + + // if there are more in the group than what's needed + // take the requested number, remove them from the set + // and return them + var toReturn = set.TakeWhile(_ => count-- != 0).ToList(); + foreach (var item in toReturn) + set.Remove(item); + + items = toReturn; + // returning falsemeans group is not yet deleted + // because there are items left + return false; + } + + items = Array.Empty(); + return true; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs new file mode 100644 index 0000000..aec7e9e --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs @@ -0,0 +1,662 @@ +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using System.Threading.Channels; + +namespace EllieBot.Services; + +public class GreetService : IEService, IReadyExecutor +{ + public bool GroupGreets + => _bss.Data.GroupGreets; + + private readonly DbService _db; + + private readonly ConcurrentDictionary _guildConfigsCache; + private readonly DiscordSocketClient _client; + + private readonly GreetGrouper _greets = new(); + private readonly GreetGrouper _byes = new(); + private readonly BotConfigService _bss; + private readonly IReplacementService _repSvc; + private readonly IMessageSenderService _sender; + + public GreetService( + DiscordSocketClient client, + IBot bot, + DbService db, + BotConfigService bss, + IMessageSenderService sender, + IReplacementService repSvc) + { + _db = db; + _client = client; + _bss = bss; + _repSvc = repSvc; + _sender = sender; + + _guildConfigsCache = new(bot.AllGuildConfigs.ToDictionary(g => g.GuildId, GreetSettings.Create)); + + _client.UserJoined += OnUserJoined; + _client.UserLeft += OnUserLeft; + + bot.JoinedGuild += OnBotJoinedGuild; + _client.LeftGuild += OnClientLeftGuild; + + _client.GuildMemberUpdated += ClientOnGuildMemberUpdated; + } + + public async Task OnReadyAsync() + { + while (true) + { + var (conf, user, compl) = await _greetDmQueue.Reader.ReadAsync(); + var res = await GreetDmUserInternal(conf, user); + compl.TrySetResult(res); + await Task.Delay(2000); + } + } + + private Task ClientOnGuildMemberUpdated(Cacheable optOldUser, SocketGuildUser newUser) + { + // if user is a new booster + // or boosted again the same server + if ((optOldUser.Value is { PremiumSince: null } && newUser is { PremiumSince: not null }) + || (optOldUser.Value?.PremiumSince is { } oldDate + && newUser.PremiumSince is { } newDate + && newDate > oldDate)) + { + var conf = GetOrAddSettingsForGuild(newUser.Guild.Id); + if (!conf.SendBoostMessage) + return Task.CompletedTask; + + _ = Task.Run(TriggerBoostMessage(conf, newUser)); + } + + return Task.CompletedTask; + } + + private Func TriggerBoostMessage(GreetSettings conf, SocketGuildUser user) + => async () => + { + var channel = user.Guild.GetTextChannel(conf.BoostMessageChannelId); + if (channel is null) + return; + + await SendBoostMessage(conf, user, channel); + }; + + private async Task SendBoostMessage(GreetSettings conf, IGuildUser user, ITextChannel channel) + { + if (string.IsNullOrWhiteSpace(conf.BoostMessage)) + return false; + + var toSend = SmartText.CreateFrom(conf.BoostMessage); + + try + { + var newContent = await _repSvc.ReplaceAsync(toSend, + new(client: _client, guild: user.Guild, channel: channel, users: user)); + var toDelete = await _sender.Response(channel).Text(newContent).Sanitize(false).SendAsync(); + if (conf.BoostMessageDeleteAfter > 0) + toDelete.DeleteAfter(conf.BoostMessageDeleteAfter); + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error sending boost message"); + } + + return false; + } + + private Task OnClientLeftGuild(SocketGuild arg) + { + _guildConfigsCache.TryRemove(arg.Id, out _); + return Task.CompletedTask; + } + + private Task OnBotJoinedGuild(GuildConfig gc) + { + _guildConfigsCache[gc.GuildId] = GreetSettings.Create(gc); + return Task.CompletedTask; + } + + private Task OnUserLeft(SocketGuild guild, SocketUser user) + { + _ = Task.Run(async () => + { + try + { + var conf = GetOrAddSettingsForGuild(guild.Id); + + if (!conf.SendChannelByeMessage) + return; + var channel = guild.TextChannels.FirstOrDefault(c => c.Id == conf.ByeMessageChannelId); + + if (channel is null) //maybe warn the server owner that the channel is missing + return; + + if (GroupGreets) + { + // if group is newly created, greet that user right away, + // but any user which joins in the next 5 seconds will + // be greeted in a group greet + if (_byes.CreateOrAdd(guild.Id, user)) + { + // greet single user + await ByeUsers(conf, channel, new[] { user }); + var groupClear = false; + while (!groupClear) + { + await Task.Delay(5000); + groupClear = _byes.ClearGroup(guild.Id, 5, out var toBye); + await ByeUsers(conf, channel, toBye); + } + } + } + else + await ByeUsers(conf, channel, new[] { user }); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public string? GetDmGreetMsg(ulong id) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(id, set => set).DmGreetMessageText; + } + + public string? GetGreetMsg(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).ChannelGreetMessageText; + } + + public string? GetBoostMessage(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).BoostMessage; + } + + public GreetSettings GetGreetSettings(ulong gid) + { + if (_guildConfigsCache.TryGetValue(gid, out var gs)) + return gs; + + using var uow = _db.GetDbContext(); + return GreetSettings.Create(uow.GuildConfigsForId(gid, set => set)); + } + + private Task ByeUsers(GreetSettings conf, ITextChannel channel, IUser user) + => ByeUsers(conf, channel, new[] { user }); + + private async Task ByeUsers(GreetSettings conf, ITextChannel channel, IReadOnlyCollection users) + { + if (!users.Any()) + return; + + var repCtx = new ReplacementContext(client: _client, + guild: channel.Guild, + channel: channel, + users: users.ToArray()); + + var text = SmartText.CreateFrom(conf.ChannelByeMessageText); + text = await _repSvc.ReplaceAsync(text, repCtx); + try + { + var toDelete = await _sender.Response(channel).Text(text).Sanitize(false).SendAsync(); + if (conf.AutoDeleteByeMessagesTimer > 0) + toDelete.DeleteAfter(conf.AutoDeleteByeMessagesTimer); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.InsufficientPermissions + || ex.DiscordCode == DiscordErrorCode.MissingPermissions + || ex.DiscordCode == DiscordErrorCode.UnknownChannel) + { + Log.Warning(ex, + "Missing permissions to send a bye message, the bye message will be disabled on server: {GuildId}", + channel.GuildId); + await SetBye(channel.GuildId, channel.Id, false); + } + catch (Exception ex) + { + Log.Warning(ex, "Error embeding bye message"); + } + } + + private Task GreetUsers(GreetSettings conf, ITextChannel channel, IGuildUser user) + => GreetUsers(conf, channel, new[] { user }); + + private async Task GreetUsers(GreetSettings conf, ITextChannel channel, IReadOnlyCollection users) + { + if (users.Count == 0) + return; + + var repCtx = new ReplacementContext(client: _client, + guild: channel.Guild, + channel: channel, + users: users.ToArray()); + + var text = SmartText.CreateFrom(conf.ChannelGreetMessageText); + text = await _repSvc.ReplaceAsync(text, repCtx); + try + { + var toDelete = await _sender.Response(channel).Text(text).Sanitize(false).SendAsync(); + if (conf.AutoDeleteGreetMessagesTimer > 0) + toDelete.DeleteAfter(conf.AutoDeleteGreetMessagesTimer); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.InsufficientPermissions + || ex.DiscordCode == DiscordErrorCode.MissingPermissions + || ex.DiscordCode == DiscordErrorCode.UnknownChannel) + { + Log.Warning(ex, + "Missing permissions to send a bye message, the greet message will be disabled on server: {GuildId}", + channel.GuildId); + await SetGreet(channel.GuildId, channel.Id, false); + } + catch (Exception ex) + { + Log.Warning(ex, "Error embeding greet message"); + } + } + + private readonly Channel<(GreetSettings, IGuildUser, TaskCompletionSource)> _greetDmQueue = + Channel.CreateBounded<(GreetSettings, IGuildUser, TaskCompletionSource)>(new BoundedChannelOptions(60) + { + // The limit of 60 users should be only hit when there's a raid. In that case + // probably the best thing to do is to drop newest (raiding) users + FullMode = BoundedChannelFullMode.DropNewest + }); + + + private async Task GreetDmUser(GreetSettings conf, IGuildUser user) + { + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await _greetDmQueue.Writer.WriteAsync((conf, user, completionSource)); + return await completionSource.Task; + } + + private async Task GreetDmUserInternal(GreetSettings conf, IGuildUser user) + { + try + { + // var rep = new ReplacementBuilder() + // .WithUser(user) + // .WithServer(_client, (SocketGuild)user.Guild) + // .Build(); + + var repCtx = new ReplacementContext(client: _client, guild: user.Guild, users: user); + var smartText = SmartText.CreateFrom(conf.DmGreetMessageText); + smartText = await _repSvc.ReplaceAsync(smartText, repCtx); + + if (smartText is SmartPlainText pt) + { + smartText = new SmartEmbedText() + { + Description = pt.Text + }; + } + + if (smartText is SmartEmbedText set) + { + smartText = set with + { + Footer = CreateFooterSource(user) + }; + } + else if (smartText is SmartEmbedTextArray seta) + { + // if the greet dm message is a text array + var ebElem = seta.Embeds.LastOrDefault(); + if (ebElem is null) + { + // if there are no embeds, add an embed with the footer + smartText = seta with + { + Embeds = + [ + new SmartEmbedArrayElementText() + { + Footer = CreateFooterSource(user) + } + ] + }; + } + else + { + // if the maximum amount of embeds is reached, edit the last embed + if (seta.Embeds.Length >= 10) + { + seta.Embeds[^1] = seta.Embeds[^1] with + { + Footer = CreateFooterSource(user) + }; + } + else + { + // if there is less than 10 embeds, add an embed with footer only + seta.Embeds = seta.Embeds.Append(new SmartEmbedArrayElementText() + { + Footer = CreateFooterSource(user) + }) + .ToArray(); + } + } + } + + await _sender.Response(user).Text(smartText).Sanitize(false).SendAsync(); + } + catch + { + return false; + } + + return true; + } + + private static SmartTextEmbedFooter CreateFooterSource(IGuildUser user) + => new() + { + Text = $"This message was sent from {user.Guild} server.", + IconUrl = user.Guild.IconUrl + }; + + private Task OnUserJoined(IGuildUser user) + { + _ = Task.Run(async () => + { + try + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + + if (conf.SendChannelGreetMessage) + { + var channel = await user.Guild.GetTextChannelAsync(conf.GreetMessageChannelId); + if (channel is not null) + { + if (GroupGreets) + { + // if group is newly created, greet that user right away, + // but any user which joins in the next 5 seconds will + // be greeted in a group greet + if (_greets.CreateOrAdd(user.GuildId, user)) + { + // greet single user + await GreetUsers(conf, channel, new[] { user }); + var groupClear = false; + while (!groupClear) + { + await Task.Delay(5000); + groupClear = _greets.ClearGroup(user.GuildId, 5, out var toGreet); + await GreetUsers(conf, channel, toGreet); + } + } + } + else + await GreetUsers(conf, channel, new[] { user }); + } + } + + if (conf.SendDmGreetMessage) + await GreetDmUser(conf, user); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public string? GetByeMessage(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).ChannelByeMessageText; + } + + public GreetSettings GetOrAddSettingsForGuild(ulong guildId) + { + if (_guildConfigsCache.TryGetValue(guildId, out var settings)) + return settings; + + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guildId, set => set); + settings = GreetSettings.Create(gc); + } + + _guildConfigsCache.TryAdd(guildId, settings); + return settings; + } + + public async Task SetGreet(ulong guildId, ulong channelId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendChannelGreetMessage = value ?? !conf.SendChannelGreetMessage; + conf.GreetMessageChannelId = channelId; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetGreetMessage(ulong guildId, ref string message) + { + message = message.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.ChannelGreetMessageText = message; + var greetMsgEnabled = conf.SendChannelGreetMessage; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache.AddOrUpdate(guildId, toAdd, (_, _) => toAdd); + + uow.SaveChanges(); + return greetMsgEnabled; + } + + public async Task SetGreetDm(ulong guildId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendDmGreetMessage = value ?? !conf.SendDmGreetMessage; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetGreetDmMessage(ulong guildId, ref string? message) + { + message = message?.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.DmGreetMessageText = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendDmGreetMessage; + } + + public async Task SetBye(ulong guildId, ulong channelId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendChannelByeMessage = value ?? !conf.SendChannelByeMessage; + conf.ByeMessageChannelId = channelId; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetByeMessage(ulong guildId, ref string? message) + { + message = message?.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.ChannelByeMessageText = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendChannelByeMessage; + } + + public async Task SetByeDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + return; + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.AutoDeleteByeMessagesTimer = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public async Task SetGreetDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + return; + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.AutoDeleteGreetMessagesTimer = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public bool SetBoostMessage(ulong guildId, ref string message) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.BoostMessage = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendBoostMessage; + } + + public async Task SetBoostDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + throw new ArgumentOutOfRangeException(nameof(timer)); + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.BoostMessageDeleteAfter = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public async Task ToggleBoost(ulong guildId, ulong channelId, bool? forceState = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + + if (forceState is not bool fs) + conf.SendBoostMessage = !conf.SendBoostMessage; + else + conf.SendBoostMessage = fs; + + conf.BoostMessageChannelId = channelId; + await uow.SaveChangesAsync(); + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + return conf.SendBoostMessage; + } + + #region Get Enabled Status + + public bool GetGreetDmEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendDmGreetMessage; + } + + public bool GetGreetEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendChannelGreetMessage; + } + + public bool GetByeEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendChannelByeMessage; + } + + #endregion + + #region Test Messages + + public Task ByeTest(ITextChannel channel, IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return ByeUsers(conf, channel, user); + } + + public Task GreetTest(ITextChannel channel, IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return GreetUsers(conf, channel, user); + } + + public Task GreetDmTest(IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return GreetDmUser(conf, user); + } + + public Task BoostTest(ITextChannel channel, IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return SendBoostMessage(conf, user, channel); + } + + #endregion +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs new file mode 100644 index 0000000..6e4f8ed --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs @@ -0,0 +1,45 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Services; + +public class GreetSettings +{ + public int AutoDeleteGreetMessagesTimer { get; set; } + public int AutoDeleteByeMessagesTimer { get; set; } + + public ulong GreetMessageChannelId { get; set; } + public ulong ByeMessageChannelId { get; set; } + + public bool SendDmGreetMessage { get; set; } + public string? DmGreetMessageText { get; set; } + + public bool SendChannelGreetMessage { get; set; } + public string? ChannelGreetMessageText { get; set; } + + public bool SendChannelByeMessage { get; set; } + public string? ChannelByeMessageText { get; set; } + + public bool SendBoostMessage { get; set; } + public string? BoostMessage { get; set; } + public int BoostMessageDeleteAfter { get; set; } + public ulong BoostMessageChannelId { get; set; } + + public static GreetSettings Create(GuildConfig g) + => new() + { + AutoDeleteByeMessagesTimer = g.AutoDeleteByeMessagesTimer, + AutoDeleteGreetMessagesTimer = g.AutoDeleteGreetMessagesTimer, + GreetMessageChannelId = g.GreetMessageChannelId, + ByeMessageChannelId = g.ByeMessageChannelId, + SendDmGreetMessage = g.SendDmGreetMessage, + DmGreetMessageText = g.DmGreetMessageText, + SendChannelGreetMessage = g.SendChannelGreetMessage, + ChannelGreetMessageText = g.ChannelGreetMessageText, + SendChannelByeMessage = g.SendChannelByeMessage, + ChannelByeMessageText = g.ChannelByeMessageText, + SendBoostMessage = g.SendBoostMessage, + BoostMessage = g.BoostMessage, + BoostMessageDeleteAfter = g.BoostMessageDeleteAfter, + BoostMessageChannelId = g.BoostMessageChannelId + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs b/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs new file mode 100644 index 0000000..1b45cc6 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs @@ -0,0 +1,235 @@ +#nullable disable +using LinqToDB; +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using System.Net; +using System.Threading.Channels; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class SomethingOnlyChannelService : IExecOnMessage +{ + public int Priority { get; } = 0; + private readonly IMemoryCache _ticketCache; + private readonly DiscordSocketClient _client; + private readonly DbService _db; + private readonly ConcurrentDictionary> _imageOnly; + private readonly ConcurrentDictionary> _linkOnly; + + private readonly Channel _deleteQueue = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + + public SomethingOnlyChannelService(IMemoryCache ticketCache, DiscordSocketClient client, DbService db) + { + _ticketCache = ticketCache; + _client = client; + _db = db; + + using var uow = _db.GetDbContext(); + _imageOnly = uow.Set() + .Where(x => x.Type == OnlyChannelType.Image) + .ToList() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, x => new ConcurrentHashSet(x.Select(y => y.ChannelId))) + .ToConcurrent(); + + _linkOnly = uow.Set() + .Where(x => x.Type == OnlyChannelType.Link) + .ToList() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, x => new ConcurrentHashSet(x.Select(y => y.ChannelId))) + .ToConcurrent(); + + _ = Task.Run(DeleteQueueRunner); + + _client.ChannelDestroyed += ClientOnChannelDestroyed; + } + + private async Task ClientOnChannelDestroyed(SocketChannel ch) + { + if (ch is not IGuildChannel gch) + return; + + if (_imageOnly.TryGetValue(gch.GuildId, out var channels) && channels.TryRemove(ch.Id)) + await ToggleImageOnlyChannelAsync(gch.GuildId, ch.Id, true); + } + + private async Task DeleteQueueRunner() + { + while (true) + { + var toDelete = await _deleteQueue.Reader.ReadAsync(); + try + { + await toDelete.DeleteAsync(); + await Task.Delay(1000); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + // disable if bot can't delete messages in the channel + await ToggleImageOnlyChannelAsync(((ITextChannel)toDelete.Channel).GuildId, toDelete.Channel.Id, true); + } + } + } + + public async Task ToggleImageOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false) + { + var newState = false; + await using var uow = _db.GetDbContext(); + if (forceDisable || (_imageOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId))) + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Image); + } + else + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId); + uow.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + Type = OnlyChannelType.Image + }); + + if (_linkOnly.TryGetValue(guildId, out var chs)) + chs.TryRemove(channelId); + + channels = _imageOnly.GetOrAdd(guildId, new ConcurrentHashSet()); + channels.Add(channelId); + newState = true; + } + + await uow.SaveChangesAsync(); + return newState; + } + + public async Task ToggleLinkOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false) + { + var newState = false; + await using var uow = _db.GetDbContext(); + if (forceDisable || (_linkOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId))) + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Link); + } + else + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId); + uow.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + Type = OnlyChannelType.Link + }); + + if (_imageOnly.TryGetValue(guildId, out var chs)) + chs.TryRemove(channelId); + + channels = _linkOnly.GetOrAdd(guildId, new ConcurrentHashSet()); + channels.Add(channelId); + newState = true; + } + + await uow.SaveChangesAsync(); + return newState; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + if (msg.Channel is not ITextChannel tch) + return false; + + if (_imageOnly.TryGetValue(tch.GuildId, out var chs) && chs.Contains(msg.Channel.Id)) + return await HandleOnlyChannel(tch, msg, OnlyChannelType.Image); + + if (_linkOnly.TryGetValue(tch.GuildId, out chs) && chs.Contains(msg.Channel.Id)) + return await HandleOnlyChannel(tch, msg, OnlyChannelType.Link); + + return false; + } + + private async Task HandleOnlyChannel(ITextChannel tch, IUserMessage msg, OnlyChannelType type) + { + if (type == OnlyChannelType.Image) + { + if (msg.Attachments.Any(x => x is { Height: > 0, Width: > 0 })) + return false; + } + else + { + if (msg.Content.TryGetUrlPath(out _)) + return false; + } + + var user = await tch.Guild.GetUserAsync(msg.Author.Id) + ?? await _client.Rest.GetGuildUserAsync(tch.GuildId, msg.Author.Id); + + if (user is null) + return false; + + // ignore owner and admin + if (user.Id == tch.Guild.OwnerId || user.GuildPermissions.Administrator) + { + Log.Information("{Type}-Only Channel: Ignoring owner or admin ({ChannelId})", type, msg.Channel.Id); + return false; + } + + // ignore users higher in hierarchy + var botUser = await tch.Guild.GetCurrentUserAsync(); + if (user.GetRoles().Max(x => x.Position) >= botUser.GetRoles().Max(x => x.Position)) + return false; + + if (!botUser.GetPermissions(tch).ManageChannel) + { + if(type == OnlyChannelType.Image) + await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true); + else + await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true); + + return false; + } + + var shouldLock = AddUserTicket(tch.GuildId, msg.Author.Id); + if (shouldLock) + { + await tch.AddPermissionOverwriteAsync(msg.Author, new(sendMessages: PermValue.Deny)); + Log.Warning("{Type}-Only Channel: User {User} [{UserId}] has been banned from typing in the channel [{ChannelId}]", + type, + msg.Author, + msg.Author.Id, + msg.Channel.Id); + } + + try + { + await _deleteQueue.Writer.WriteAsync(msg); + } + catch (Exception ex) + { + Log.Error(ex, "Error deleting message {MessageId} in image-only channel {ChannelId}", msg.Id, tch.Id); + } + + return true; + } + + private bool AddUserTicket(ulong guildId, ulong userId) + { + var old = _ticketCache.GetOrCreate($"{guildId}_{userId}", + entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); + return 0; + }); + + _ticketCache.Set($"{guildId}_{userId}", ++old); + + // if this is the third time that the user posts a + // non image in an image-only channel on this server + return old > 2; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/LocalizationCommands.cs b/src/EllieBot/Modules/Administration/LocalizationCommands.cs new file mode 100644 index 0000000..9715d0b --- /dev/null +++ b/src/EllieBot/Modules/Administration/LocalizationCommands.cs @@ -0,0 +1,264 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class LocalizationCommands : EllieModule + { + private static readonly IReadOnlyDictionary _supportedLocales = new Dictionary + { + { "ar", "العربية" }, + { "zh-TW", "繁體中文, 台灣" }, + { "zh-CN", "简体中文, 中华人民共和国" }, + { "nl-NL", "Nederlands, Nederland" }, + { "en-US", "English, United States" }, + { "fr-FR", "Français, France" }, + { "cs-CZ", "Čeština, Česká republika" }, + { "da-DK", "Dansk, Danmark" }, + { "de-DE", "Deutsch, Deutschland" }, + { "he-IL", "עברית, ישראל" }, + { "hu-HU", "Magyar, Magyarország" }, + { "id-ID", "Bahasa Indonesia, Indonesia" }, + { "it-IT", "Italiano, Italia" }, + { "ja-JP", "日本語, 日本" }, + { "ko-KR", "한국어, 대한민국" }, + { "nb-NO", "Norsk, Norge" }, + { "pl-PL", "Polski, Polska" }, + { "pt-BR", "Português Brasileiro, Brasil" }, + { "ro-RO", "Română, România" }, + { "ru-RU", "Русский, Россия" }, + { "sr-Cyrl-RS", "Српски, Србија" }, + { "es-ES", "Español, España" }, + { "sv-SE", "Svenska, Sverige" }, + { "tr-TR", "Türkçe, Türkiye" }, + { "ts-TS", "Tsundere, You Baka" }, + { "uk-UA", "Українська, Україна" } + }; + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task LanguageSet() + => await Response().Confirm(strs.lang_set_show(Format.Bold(Culture.ToString()), + Format.Bold(Culture.NativeName))).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public async Task LanguageSet(string name) + { + try + { + CultureInfo ci; + if (name.Trim().ToLowerInvariant() == "default") + { + _localization.RemoveGuildCulture(ctx.Guild); + ci = _localization.DefaultCultureInfo; + } + else + { + ci = new CultureInfo(name); + if (!_supportedLocales.ContainsKey(ci.Name)) + { + await LanguagesList(); + return; + } + + _localization.SetGuildCulture(ctx.Guild, ci); + } + + var nativeName = ci.NativeName; + if (ci.Name == "ts-TS") + nativeName = _supportedLocales[ci.Name]; + await Response().Confirm(strs.lang_set(Format.Bold(ci.ToString()), Format.Bold(nativeName))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.lang_set_fail).SendAsync(); + } + } + + [Cmd] + public async Task LanguageSetDefault() + { + var cul = _localization.DefaultCultureInfo; + await Response().Error(strs.lang_set_bot_show(cul, cul.NativeName)).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task LanguageSetDefault(string name) + { + try + { + CultureInfo ci; + if (name.Trim().ToLowerInvariant() == "default") + { + _localization.ResetDefaultCulture(); + ci = _localization.DefaultCultureInfo; + } + else + { + ci = new CultureInfo(name); + if (!_supportedLocales.ContainsKey(ci.Name)) + { + await LanguagesList(); + return; + } + _localization.SetDefaultCulture(ci); + } + + await Response().Confirm(strs.lang_set_bot(Format.Bold(ci.ToString()), + Format.Bold(ci.NativeName))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.lang_set_fail).SendAsync(); + } + } + + [Cmd] + public async Task LanguagesList() + => await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.lang_list)) + .WithDescription(string.Join("\n", + _supportedLocales.Select( + x => $"{Format.Code(x.Key),-10} => {x.Value}")))).SendAsync(); + } +} +/* list of language codes for reference. + * taken from https://github.com/dotnet/coreclr/blob/ee5862c6a257e60e263537d975ab6c513179d47f/src/mscorlib/src/System/Globalization/CultureData.cs#L192 + { "029", "en-029" }, + { "AE", "ar-AE" }, + { "AF", "prs-AF" }, + { "AL", "sq-AL" }, + { "AM", "hy-AM" }, + { "AR", "es-AR" }, + { "AT", "de-AT" }, + { "AU", "en-AU" }, + { "AZ", "az-Cyrl-AZ" }, + { "BA", "bs-Latn-BA" }, + { "BD", "bn-BD" }, + { "BE", "nl-BE" }, + { "BG", "bg-BG" }, + { "BH", "ar-BH" }, + { "BN", "ms-BN" }, + { "BO", "es-BO" }, + { "BR", "pt-BR" }, + { "BY", "be-BY" }, + { "BZ", "en-BZ" }, + { "CA", "en-CA" }, + { "CH", "it-CH" }, + { "CL", "es-CL" }, + { "CN", "zh-CN" }, + { "CO", "es-CO" }, + { "CR", "es-CR" }, + { "CS", "sr-Cyrl-CS" }, + { "CZ", "cs-CZ" }, + { "DE", "de-DE" }, + { "DK", "da-DK" }, + { "DO", "es-DO" }, + { "DZ", "ar-DZ" }, + { "EC", "es-EC" }, + { "EE", "et-EE" }, + { "EG", "ar-EG" }, + { "ES", "es-ES" }, + { "ET", "am-ET" }, + { "FI", "fi-FI" }, + { "FO", "fo-FO" }, + { "FR", "fr-FR" }, + { "GB", "en-GB" }, + { "GE", "ka-GE" }, + { "GL", "kl-GL" }, + { "GR", "el-GR" }, + { "GT", "es-GT" }, + { "HK", "zh-HK" }, + { "HN", "es-HN" }, + { "HR", "hr-HR" }, + { "HU", "hu-HU" }, + { "ID", "id-ID" }, + { "IE", "en-IE" }, + { "IL", "he-IL" }, + { "IN", "hi-IN" }, + { "IQ", "ar-IQ" }, + { "IR", "fa-IR" }, + { "IS", "is-IS" }, + { "IT", "it-IT" }, + { "IV", "" }, + { "JM", "en-JM" }, + { "JO", "ar-JO" }, + { "JP", "ja-JP" }, + { "KE", "sw-KE" }, + { "KG", "ky-KG" }, + { "KH", "km-KH" }, + { "KR", "ko-KR" }, + { "KW", "ar-KW" }, + { "KZ", "kk-KZ" }, + { "LA", "lo-LA" }, + { "LB", "ar-LB" }, + { "LI", "de-LI" }, + { "LK", "si-LK" }, + { "LT", "lt-LT" }, + { "LU", "lb-LU" }, + { "LV", "lv-LV" }, + { "LY", "ar-LY" }, + { "MA", "ar-MA" }, + { "MC", "fr-MC" }, + { "ME", "sr-Latn-ME" }, + { "MK", "mk-MK" }, + { "MN", "mn-MN" }, + { "MO", "zh-MO" }, + { "MT", "mt-MT" }, + { "MV", "dv-MV" }, + { "MX", "es-MX" }, + { "MY", "ms-MY" }, + { "NG", "ig-NG" }, + { "NI", "es-NI" }, + { "NL", "nl-NL" }, + { "NO", "nn-NO" }, + { "NP", "ne-NP" }, + { "NZ", "en-NZ" }, + { "OM", "ar-OM" }, + { "PA", "es-PA" }, + { "PE", "es-PE" }, + { "PH", "en-PH" }, + { "PK", "ur-PK" }, + { "PL", "pl-PL" }, + { "PR", "es-PR" }, + { "PT", "pt-PT" }, + { "PY", "es-PY" }, + { "QA", "ar-QA" }, + { "RO", "ro-RO" }, + { "RS", "sr-Latn-RS" }, + { "RU", "ru-RU" }, + { "RW", "rw-RW" }, + { "SA", "ar-SA" }, + { "SE", "sv-SE" }, + { "SG", "zh-SG" }, + { "SI", "sl-SI" }, + { "SK", "sk-SK" }, + { "SN", "wo-SN" }, + { "SV", "es-SV" }, + { "SY", "ar-SY" }, + { "TH", "th-TH" }, + { "TJ", "tg-Cyrl-TJ" }, + { "TM", "tk-TM" }, + { "TN", "ar-TN" }, + { "TR", "tr-TR" }, + { "TT", "en-TT" }, + { "TW", "zh-TW" }, + { "UA", "uk-UA" }, + { "US", "en-US" }, + { "UY", "es-UY" }, + { "UZ", "uz-Cyrl-UZ" }, + { "VE", "es-VE" }, + { "VN", "vi-VN" }, + { "YE", "ar-YE" }, + { "ZA", "af-ZA" }, + { "ZW", "en-ZW" } + */ \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Mute/MuteCommands.cs b/src/EllieBot/Modules/Administration/Mute/MuteCommands.cs new file mode 100644 index 0000000..94e797a --- /dev/null +++ b/src/EllieBot/Modules/Administration/Mute/MuteCommands.cs @@ -0,0 +1,231 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class MuteCommands : EllieModule + { + private async Task VerifyMutePermissions(IGuildUser runnerUser, IGuildUser targetUser) + { + var runnerUserRoles = runnerUser.GetRoles(); + var targetUserRoles = targetUser.GetRoles(); + if (runnerUser.Id != ctx.Guild.OwnerId + && runnerUserRoles.Max(x => x.Position) <= targetUserRoles.Max(x => x.Position)) + { + await Response().Error(strs.mute_perms).SendAsync(); + return false; + } + + return true; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task MuteRole([Leftover] IRole role = null) + { + if (role is null) + { + var muteRole = await _service.GetMuteRole(ctx.Guild); + await Response().Confirm(strs.mute_role(Format.Code(muteRole.Name))).SendAsync(); + return; + } + + if (ctx.User.Id != ctx.Guild.OwnerId + && role.Position >= ((SocketGuildUser)ctx.User).Roles.Max(x => x.Position)) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + await _service.SetMuteRoleAsync(ctx.Guild.Id, role.Name); + + await Response().Confirm(strs.mute_role_set).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + [Priority(0)] + public async Task Mute(IGuildUser target, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, target)) + return; + + await _service.MuteUser(target, ctx.User, reason: reason); + await Response().Confirm(strs.user_muted(Format.Bold(target.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception in the mute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + [Priority(1)] + public async Task Mute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, reason: reason); + await Response().Confirm(strs.user_muted_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in mute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + public async Task Unmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, reason: reason); + await Response().Confirm(strs.user_unmuted(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task ChatMute(IGuildUser user, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.MuteUser(user, ctx.User, MuteType.Chat, reason); + await Response().Confirm(strs.user_chat_mute(Format.Bold(user.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception in the chatmute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [Priority(1)] + public async Task ChatMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, MuteType.Chat, reason); + await Response().Confirm(strs.user_chat_mute_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in chatmute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task ChatUnmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.Guild.Id, user.Id, ctx.User, MuteType.Chat, reason); + await Response().Confirm(strs.user_chat_unmute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + [Priority(0)] + public async Task VoiceMute(IGuildUser user, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.MuteUser(user, ctx.User, MuteType.Voice, reason); + await Response().Confirm(strs.user_voice_mute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + [Priority(1)] + public async Task VoiceMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, MuteType.Voice, reason); + await Response().Confirm(strs.user_voice_mute_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + public async Task VoiceUnmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, MuteType.Voice, reason); + await Response().Confirm(strs.user_voice_unmute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Mute/MuteService.cs b/src/EllieBot/Modules/Administration/Mute/MuteService.cs new file mode 100644 index 0000000..a3fbea3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Mute/MuteService.cs @@ -0,0 +1,504 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public enum MuteType +{ + Voice, + Chat, + All +} + +public class MuteService : IEService +{ + public enum TimerType { Mute, Ban, AddRole } + + private static readonly OverwritePermissions _denyOverwrite = new(addReactions: PermValue.Deny, + sendMessages: PermValue.Deny, + sendMessagesInThreads: PermValue.Deny, + attachFiles: PermValue.Deny); + + public event Action UserMuted = delegate { }; + public event Action UserUnmuted = delegate { }; + + public ConcurrentDictionary GuildMuteRoles { get; } + public ConcurrentDictionary> MutedUsers { get; } + + public ConcurrentDictionary> UnTimers { get; } = new(); + + private readonly DiscordSocketClient _client; + private readonly DbService _db; + private readonly IMessageSenderService _sender; + + public MuteService(DiscordSocketClient client, DbService db, IMessageSenderService sender) + { + _client = client; + _db = db; + _sender = sender; + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set() + .AsNoTracking() + .AsSplitQuery() + .Include(x => x.MutedUsers) + .Include(x => x.UnbanTimer) + .Include(x => x.UnmuteTimers) + .Include(x => x.UnroleTimer) + .Where(x => guildIds.Contains(x.GuildId)) + .ToList(); + + GuildMuteRoles = configs.Where(c => !string.IsNullOrWhiteSpace(c.MuteRoleName)) + .ToDictionary(c => c.GuildId, c => c.MuteRoleName) + .ToConcurrent(); + + MutedUsers = new(configs.ToDictionary(k => k.GuildId, + v => new ConcurrentHashSet(v.MutedUsers.Select(m => m.UserId)))); + + var max = TimeSpan.FromDays(49); + + foreach (var conf in configs) + { + foreach (var x in conf.UnmuteTimers) + { + TimeSpan after; + if (x.UnmuteAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unmute = x.UnmuteAt - DateTime.UtcNow; + after = unmute > max ? max : unmute; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Mute); + } + + foreach (var x in conf.UnbanTimer) + { + TimeSpan after; + if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unban = x.UnbanAt - DateTime.UtcNow; + after = unban > max ? max : unban; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Ban); + } + + foreach (var x in conf.UnroleTimer) + { + TimeSpan after; + if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unban = x.UnbanAt - DateTime.UtcNow; + after = unban > max ? max : unban; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.AddRole, x.RoleId); + } + } + + _client.UserJoined += Client_UserJoined; + } + + UserMuted += OnUserMuted; + UserUnmuted += OnUserUnmuted; + } + + private void OnUserMuted( + IGuildUser user, + IUser mod, + MuteType type, + string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return; + + _ = Task.Run(() => _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithDescription($"You've been muted in {user.Guild} server") + .AddField("Mute Type", type.ToString()) + .AddField("Moderator", mod.ToString()) + .AddField("Reason", reason)) + .SendAsync()); + } + + private void OnUserUnmuted( + IGuildUser user, + IUser mod, + MuteType type, + string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return; + + _ = Task.Run(() => _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithDescription($"You've been unmuted in {user.Guild} server") + .AddField("Unmute Type", type.ToString()) + .AddField("Moderator", mod.ToString()) + .AddField("Reason", reason)) + .SendAsync()); + } + + private Task Client_UserJoined(IGuildUser usr) + { + try + { + MutedUsers.TryGetValue(usr.Guild.Id, out var muted); + + if (muted is null || !muted.Contains(usr.Id)) + return Task.CompletedTask; + _ = Task.Run(() => MuteUser(usr, _client.CurrentUser, reason: "Sticky mute")); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in MuteService UserJoined event"); + } + + return Task.CompletedTask; + } + + public async Task SetMuteRoleAsync(ulong guildId, string name) + { + await using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + config.MuteRoleName = name; + GuildMuteRoles.AddOrUpdate(guildId, name, (_, _) => name); + await uow.SaveChangesAsync(); + } + + public async Task MuteUser( + IGuildUser usr, + IUser mod, + MuteType type = MuteType.All, + string reason = "") + { + if (type == MuteType.All) + { + try { await usr.ModifyAsync(x => x.Mute = true); } + catch { } + + var muteRole = await GetMuteRole(usr.Guild); + if (!usr.RoleIds.Contains(muteRole.Id)) + await usr.AddRoleAsync(muteRole); + StopTimer(usr.GuildId, usr.Id, TimerType.Mute); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(usr.Guild.Id, + set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers)); + config.MutedUsers.Add(new() + { + UserId = usr.Id + }); + if (MutedUsers.TryGetValue(usr.Guild.Id, out var muted)) + muted.Add(usr.Id); + + config.UnmuteTimers.RemoveWhere(x => x.UserId == usr.Id); + + await uow.SaveChangesAsync(); + } + + UserMuted(usr, mod, MuteType.All, reason); + } + else if (type == MuteType.Voice) + { + try + { + await usr.ModifyAsync(x => x.Mute = true); + UserMuted(usr, mod, MuteType.Voice, reason); + } + catch { } + } + else if (type == MuteType.Chat) + { + await usr.AddRoleAsync(await GetMuteRole(usr.Guild)); + UserMuted(usr, mod, MuteType.Chat, reason); + } + } + + public async Task UnmuteUser( + ulong guildId, + ulong usrId, + IUser mod, + MuteType type = MuteType.All, + string reason = "") + { + var usr = _client.GetGuild(guildId)?.GetUser(usrId); + if (type == MuteType.All) + { + StopTimer(guildId, usrId, TimerType.Mute); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, + set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers)); + var match = new MutedUserId + { + UserId = usrId + }; + var toRemove = config.MutedUsers.FirstOrDefault(x => x.Equals(match)); + if (toRemove is not null) + uow.Remove(toRemove); + if (MutedUsers.TryGetValue(guildId, out var muted)) + muted.TryRemove(usrId); + + config.UnmuteTimers.RemoveWhere(x => x.UserId == usrId); + + await uow.SaveChangesAsync(); + } + + if (usr is not null) + { + try { await usr.ModifyAsync(x => x.Mute = false); } + catch { } + + try { await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild)); } + catch + { + /*ignore*/ + } + + UserUnmuted(usr, mod, MuteType.All, reason); + } + } + else if (type == MuteType.Voice) + { + if (usr is null) + return; + try + { + await usr.ModifyAsync(x => x.Mute = false); + UserUnmuted(usr, mod, MuteType.Voice, reason); + } + catch { } + } + else if (type == MuteType.Chat) + { + if (usr is null) + return; + await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild)); + UserUnmuted(usr, mod, MuteType.Chat, reason); + } + } + + public async Task GetMuteRole(IGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + + const string defaultMuteRoleName = "ellie-mute"; + + var muteRoleName = GuildMuteRoles.GetOrAdd(guild.Id, defaultMuteRoleName); + + var muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName); + if (muteRole is null) + //if it doesn't exist, create it + { + try { muteRole = await guild.CreateRoleAsync(muteRoleName, isMentionable: false); } + catch + { + //if creations fails, maybe the name is not correct, find default one, if doesn't work, create default one + muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName) + ?? await guild.CreateRoleAsync(defaultMuteRoleName, isMentionable: false); + } + } + + foreach (var toOverwrite in await guild.GetTextChannelsAsync()) + { + try + { + if (!toOverwrite.PermissionOverwrites.Any(x => x.TargetId == muteRole.Id + && x.TargetType == PermissionTarget.Role)) + { + await toOverwrite.AddPermissionOverwriteAsync(muteRole, _denyOverwrite); + + await Task.Delay(200); + } + } + catch + { + // ignored + } + } + + return muteRole; + } + + public async Task TimedMute( + IGuildUser user, + IUser mod, + TimeSpan after, + MuteType muteType = MuteType.All, + string reason = "") + { + await MuteUser(user, mod, muteType, reason); // mute the user. This will also remove any previous unmute timers + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnmuteTimers)); + config.UnmuteTimers.Add(new() + { + UserId = user.Id, + UnmuteAt = DateTime.UtcNow + after + }); // add teh unmute timer to the database + uow.SaveChanges(); + } + + StartUn_Timer(user.GuildId, user.Id, after, TimerType.Mute); // start the timer + } + + public async Task TimedBan( + IGuild guild, + ulong userId, + TimeSpan after, + string reason, + int pruneDays) + { + await guild.AddBanAsync(userId, pruneDays, reason); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.UnbanTimer)); + config.UnbanTimer.Add(new() + { + UserId = userId, + UnbanAt = DateTime.UtcNow + after + }); // add teh unmute timer to the database + await uow.SaveChangesAsync(); + } + + StartUn_Timer(guild.Id, userId, after, TimerType.Ban); // start the timer + } + + public async Task TimedRole( + IGuildUser user, + TimeSpan after, + string reason, + IRole role) + { + await user.AddRoleAsync(role); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnroleTimer)); + config.UnroleTimer.Add(new() + { + UserId = user.Id, + UnbanAt = DateTime.UtcNow + after, + RoleId = role.Id + }); // add teh unmute timer to the database + uow.SaveChanges(); + } + + StartUn_Timer(user.GuildId, user.Id, after, TimerType.AddRole, role.Id); // start the timer + } + + public void StartUn_Timer( + ulong guildId, + ulong userId, + TimeSpan after, + TimerType type, + ulong? roleId = null) + { + //load the unmute timers for this guild + var userUnTimers = UnTimers.GetOrAdd(guildId, new ConcurrentDictionary<(ulong, TimerType), Timer>()); + + //unmute timer to be added + var toAdd = new Timer(async _ => + { + if (type == TimerType.Ban) + { + try + { + RemoveTimerFromDb(guildId, userId, type); + StopTimer(guildId, userId, type); + var guild = _client.GetGuild(guildId); // load the guild + if (guild is not null) + await guild.RemoveBanAsync(userId); + } + catch (Exception ex) + { + Log.Warning(ex, "Couldn't unban user {UserId} in guild {GuildId}", userId, guildId); + } + } + else if (type == TimerType.AddRole) + { + try + { + if (roleId is null) + return; + + RemoveTimerFromDb(guildId, userId, type); + StopTimer(guildId, userId, type); + var guild = _client.GetGuild(guildId); + var user = guild?.GetUser(userId); + var role = guild?.GetRole(roleId.Value); + if (guild is not null && user is not null && user.Roles.Contains(role)) + await user.RemoveRoleAsync(role); + } + catch (Exception ex) + { + Log.Warning(ex, "Couldn't remove role from user {UserId} in guild {GuildId}", userId, guildId); + } + } + else + { + try + { + // unmute the user, this will also remove the timer from the db + await UnmuteUser(guildId, userId, _client.CurrentUser, reason: "Timed mute expired"); + } + catch (Exception ex) + { + RemoveTimerFromDb(guildId, userId, type); // if unmute errored, just remove unmute from db + Log.Warning(ex, "Couldn't unmute user {UserId} in guild {GuildId}", userId, guildId); + } + } + }, + null, + after, + Timeout.InfiniteTimeSpan); + + //add it, or stop the old one and add this one + userUnTimers.AddOrUpdate((userId, type), + _ => toAdd, + (_, old) => + { + old.Change(Timeout.Infinite, Timeout.Infinite); + return toAdd; + }); + } + + public void StopTimer(ulong guildId, ulong userId, TimerType type) + { + if (!UnTimers.TryGetValue(guildId, out var userTimer)) + return; + + if (userTimer.TryRemove((userId, type), out var removed)) + removed.Change(Timeout.Infinite, Timeout.Infinite); + } + + private void RemoveTimerFromDb(ulong guildId, ulong userId, TimerType type) + { + using var uow = _db.GetDbContext(); + object toDelete; + if (type == TimerType.Mute) + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnmuteTimers)); + toDelete = config.UnmuteTimers.FirstOrDefault(x => x.UserId == userId); + } + else + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnbanTimer)); + toDelete = config.UnbanTimer.FirstOrDefault(x => x.UserId == userId); + } + + if (toDelete is not null) + uow.Remove(toDelete); + uow.SaveChanges(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs b/src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs new file mode 100644 index 0000000..cc43b31 --- /dev/null +++ b/src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs @@ -0,0 +1,83 @@ +#nullable disable +using EllieBot.Common.TypeReaders; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class DiscordPermOverrideCommands : EllieModule + { + // override stats, it should require that the user has managessages guild permission + // .po 'stats' add user guild managemessages + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverride(CommandOrExprInfo cmd, params GuildPerm[] perms) + { + if (perms is null || perms.Length == 0) + { + await _service.RemoveOverride(ctx.Guild.Id, cmd.Name); + await Response().Confirm(strs.perm_override_reset).SendAsync(); + return; + } + + var aggregatePerms = perms.Aggregate((acc, seed) => seed | acc); + await _service.AddOverride(ctx.Guild.Id, cmd.Name, aggregatePerms); + + await Response() + .Confirm(strs.perm_override(Format.Bold(aggregatePerms.ToString()), + Format.Code(cmd.Name))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverrideReset() + { + var result = await PromptUserConfirmAsync(_sender.CreateEmbed() + .WithOkColor() + .WithDescription(GetText(strs.perm_override_all_confirm))); + + if (!result) + return; + + await _service.ClearAllOverrides(ctx.Guild.Id); + + await Response().Confirm(strs.perm_override_all).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverrideList(int page = 1) + { + if (--page < 0) + return; + + var allOverrides = await _service.GetAllOverrides(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allOverrides) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + var eb = _sender.CreateEmbed().WithTitle(GetText(strs.perm_overrides)).WithOkColor(); + + if (items.Count == 0) + eb.WithDescription(GetText(strs.perm_override_page_none)); + else + { + eb.WithDescription(items.Select(ov => $"{ov.Command} => {ov.Perm.ToString()}") + .Join("\n")); + } + + return eb; + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs new file mode 100644 index 0000000..bf4909e --- /dev/null +++ b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs @@ -0,0 +1,62 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PlayingRotateCommands : EllieModule + { + [Cmd] + [OwnerOnly] + public async Task RotatePlaying() + { + if (_service.ToggleRotatePlaying()) + await Response().Confirm(strs.ropl_enabled).SendAsync(); + else + await Response().Confirm(strs.ropl_disabled).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task AddPlaying(ActivityType t, [Leftover] string status) + { + await _service.AddPlaying(t, status); + + await Response().Confirm(strs.ropl_added).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ListPlaying() + { + var statuses = _service.GetRotatingStatuses(); + + if (!statuses.Any()) + await Response().Error(strs.ropl_not_set).SendAsync(); + else + { + var i = 1; + await Response() + .Confirm(strs.ropl_list(string.Join("\n\t", + statuses.Select(rs => $"`{i++}.` *{rs.Type}* {rs.Status}")))) + .SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task RemovePlaying(int index) + { + index -= 1; + + var msg = await _service.RemovePlayingAsync(index); + + if (msg is null) + return; + + await Response().Confirm(strs.reprm(msg)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs new file mode 100644 index 0000000..edf6843 --- /dev/null +++ b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs @@ -0,0 +1,109 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class PlayingRotateService : IEService, IReadyExecutor +{ + private readonly BotConfigService _bss; + private readonly SelfService _selfService; + private readonly IReplacementService _repService; + // private readonly Replacer _rep; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public PlayingRotateService( + DiscordSocketClient client, + DbService db, + BotConfigService bss, + IEnumerable phProviders, + SelfService selfService, + IReplacementService repService) + { + _db = db; + _bss = bss; + _selfService = selfService; + _repService = repService; + _client = client; + + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + var index = 0; + while (await timer.WaitForNextTickAsync()) + { + try + { + if (!_bss.Data.RotateStatuses) + continue; + + IReadOnlyList rotatingStatuses; + await using (var uow = _db.GetDbContext()) + { + rotatingStatuses = uow.Set().AsNoTracking().OrderBy(x => x.Id).ToList(); + } + + if (rotatingStatuses.Count == 0) + continue; + + var playingStatus = index >= rotatingStatuses.Count + ? rotatingStatuses[index = 0] + : rotatingStatuses[index++]; + + var statusText = await _repService.ReplaceAsync(playingStatus.Status, new (client: _client)); + await _selfService.SetGameAsync(statusText, (ActivityType)playingStatus.Type); + } + catch (Exception ex) + { + Log.Warning(ex, "Rotating playing status errored: {ErrorMessage}", ex.Message); + } + } + } + + public async Task RemovePlayingAsync(int index) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + + await using var uow = _db.GetDbContext(); + var toRemove = await uow.Set().AsQueryable().AsNoTracking().Skip(index).FirstOrDefaultAsync(); + + if (toRemove is null) + return null; + + uow.Remove(toRemove); + await uow.SaveChangesAsync(); + return toRemove.Status; + } + + public async Task AddPlaying(ActivityType activityType, string status) + { + await using var uow = _db.GetDbContext(); + var toAdd = new RotatingPlayingStatus + { + Status = status, + Type = (EllieBot.Db.DbActivityType)activityType + }; + uow.Add(toAdd); + await uow.SaveChangesAsync(); + } + + public bool ToggleRotatePlaying() + { + var enabled = false; + _bss.ModifyConfig(bs => { enabled = bs.RotateStatuses = !bs.RotateStatuses; }); + return enabled; + } + + public IReadOnlyList GetRotatingStatuses() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().ToList(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs b/src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs new file mode 100644 index 0000000..3b8ea72 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs @@ -0,0 +1,57 @@ +#nullable disable +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PrefixCommands : EllieModule + { + public enum Set + { + Set + } + + [Cmd] + [Priority(1)] + public async Task Prefix() + => await Response().Confirm(strs.prefix_current(Format.Code(_cmdHandler.GetPrefix(ctx.Guild)))).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task Prefix(Set _, [Leftover] string newPrefix) + => Prefix(newPrefix); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public async Task Prefix([Leftover] string toSet) + { + if (string.IsNullOrWhiteSpace(prefix)) + return; + + var oldPrefix = prefix; + var newPrefix = _cmdHandler.SetPrefix(ctx.Guild, toSet); + + await Response().Confirm(strs.prefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task DefPrefix([Leftover] string toSet = null) + { + if (string.IsNullOrWhiteSpace(toSet)) + { + await Response().Confirm(strs.defprefix_current(_cmdHandler.GetPrefix())).SendAsync(); + return; + } + + var oldPrefix = _cmdHandler.GetPrefix(); + var newPrefix = _cmdHandler.SetDefaultPrefix(toSet); + + await Response().Confirm(strs.defprefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs new file mode 100644 index 0000000..64648b8 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs @@ -0,0 +1,292 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class ProtectionCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt() + { + if (await _service.TryStopAntiAlt(ctx.Guild.Id)) + { + await Response().Confirm(strs.prot_disable("Anti-Alt")).SendAsync(); + return; + } + + await Response().Confirm(strs.protection_not_running("Anti-Alt")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt( + StoopidTime minAge, + PunishmentAction action, + [Leftover] StoopidTime punishTime = null) + { + var minAgeMinutes = (int)minAge.Time.TotalMinutes; + var punishTimeMinutes = (int?)punishTime?.Time.TotalMinutes ?? 0; + + if (minAgeMinutes < 1 || punishTimeMinutes < 0) + return; + + var minutes = (int?)punishTime?.Time.TotalMinutes ?? 0; + if (action is PunishmentAction.TimeOut && minutes < 1) + minutes = 1; + + await _service.StartAntiAltAsync(ctx.Guild.Id, + minAgeMinutes, + action, + minutes); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt(StoopidTime minAge, PunishmentAction action, [Leftover] IRole role) + { + var minAgeMinutes = (int)minAge.Time.TotalMinutes; + + if (minAgeMinutes < 1) + return; + + if (action == PunishmentAction.TimeOut) + return; + + await _service.StartAntiAltAsync(ctx.Guild.Id, minAgeMinutes, action, roleId: role.Id); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task AntiRaid() + { + if (_service.TryStopAntiRaid(ctx.Guild.Id)) + return Response().Confirm(strs.prot_disable("Anti-Raid")).SendAsync(); + return Response().Pending(strs.protection_not_running("Anti-Raid")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public Task AntiRaid( + int userThreshold, + int seconds, + PunishmentAction action, + [Leftover] StoopidTime punishTime) + => InternalAntiRaid(userThreshold, seconds, action, punishTime); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(2)] + public Task AntiRaid(int userThreshold, int seconds, PunishmentAction action) + => InternalAntiRaid(userThreshold, seconds, action); + + private async Task InternalAntiRaid( + int userThreshold, + int seconds = 10, + PunishmentAction action = PunishmentAction.Mute, + StoopidTime punishTime = null) + { + if (action == PunishmentAction.AddRole) + { + await Response().Error(strs.punishment_unsupported(action)).SendAsync(); + return; + } + + if (userThreshold is < 2 or > 30) + { + await Response().Error(strs.raid_cnt(2, 30)).SendAsync(); + return; + } + + if (seconds is < 2 or > 300) + { + await Response().Error(strs.raid_time(2, 300)).SendAsync(); + return; + } + + if (punishTime is not null) + { + if (!_service.IsDurationAllowed(action)) + await Response().Error(strs.prot_cant_use_time).SendAsync(); + } + + var time = (int?)punishTime?.Time.TotalMinutes ?? 0; + if (time is < 0 or > 60 * 24) + return; + + if (action is PunishmentAction.TimeOut && time < 1) + return; + + var stats = await _service.StartAntiRaidAsync(ctx.Guild.Id, userThreshold, seconds, action, time); + + if (stats is null) + return; + + await Response() + .Confirm(GetText(strs.prot_enable("Anti-Raid")), + $"{ctx.User.Mention} {GetAntiRaidString(stats)}") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task AntiSpam() + { + if (_service.TryStopAntiSpam(ctx.Guild.Id)) + return Response().Confirm(strs.prot_disable("Anti-Spam")).SendAsync(); + return Response().Pending(strs.protection_not_running("Anti-Spam")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] IRole role) + { + if (action != PunishmentAction.AddRole) + return Task.CompletedTask; + + return InternalAntiSpam(messageCount, action, null, role); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] StoopidTime punishTime) + => InternalAntiSpam(messageCount, action, punishTime); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(2)] + public Task AntiSpam(int messageCount, PunishmentAction action) + => InternalAntiSpam(messageCount, action); + + private async Task InternalAntiSpam( + int messageCount, + PunishmentAction action, + StoopidTime timeData = null, + IRole role = null) + { + if (messageCount is < 2 or > 10) + return; + + if (timeData is not null) + { + if (!_service.IsDurationAllowed(action)) + await Response().Error(strs.prot_cant_use_time).SendAsync(); + } + + var time = (int?)timeData?.Time.TotalMinutes ?? 0; + if (time is < 0 or > 60 * 24) + return; + + if (action is PunishmentAction.TimeOut && time < 1) + return; + + var stats = await _service.StartAntiSpamAsync(ctx.Guild.Id, messageCount, action, time, role?.Id); + + await Response() + .Confirm(GetText(strs.prot_enable("Anti-Spam")), + $"{ctx.User.Mention} {GetAntiSpamString(stats)}") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntispamIgnore() + { + var added = await _service.AntiSpamIgnoreAsync(ctx.Guild.Id, ctx.Channel.Id); + + if (added is null) + { + await Response().Error(strs.protection_not_running("Anti-Spam")).SendAsync(); + return; + } + + if (added.Value) + await Response().Confirm(strs.spam_ignore("Anti-Spam")).SendAsync(); + else + await Response().Confirm(strs.spam_not_ignore("Anti-Spam")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AntiList() + { + var (spam, raid, alt) = _service.GetAntiStats(ctx.Guild.Id); + + if (spam is null && raid is null && alt is null) + { + await Response().Confirm(strs.prot_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.prot_active)); + + if (spam is not null) + embed.AddField("Anti-Spam", GetAntiSpamString(spam).TrimTo(1024), true); + + if (raid is not null) + embed.AddField("Anti-Raid", GetAntiRaidString(raid).TrimTo(1024), true); + + if (alt is not null) + embed.AddField("Anti-Alt", GetAntiAltString(alt), true); + + await Response().Embed(embed).SendAsync(); + } + + private string GetAntiAltString(AntiAltStats alt) + => GetText(strs.anti_alt_status(Format.Bold(alt.MinAge.ToString(@"dd\d\ hh\h\ mm\m\ ")), + Format.Bold(alt.Action.ToString()), + Format.Bold(alt.Counter.ToString()))); + + private string GetAntiSpamString(AntiSpamStats stats) + { + var settings = stats.AntiSpamSettings; + var ignoredString = string.Join(", ", settings.IgnoredChannels.Select(c => $"<#{c.ChannelId}>")); + + if (string.IsNullOrWhiteSpace(ignoredString)) + ignoredString = "none"; + + var add = string.Empty; + if (settings.MuteTime > 0) + add = $" ({TimeSpan.FromMinutes(settings.MuteTime):hh\\hmm\\m})"; + + return GetText(strs.spam_stats(Format.Bold(settings.MessageThreshold.ToString()), + Format.Bold(settings.Action + add), + ignoredString)); + } + + private string GetAntiRaidString(AntiRaidStats stats) + { + var actionString = Format.Bold(stats.AntiRaidSettings.Action.ToString()); + + if (stats.AntiRaidSettings.PunishDuration > 0) + actionString += $" **({TimeSpan.FromMinutes(stats.AntiRaidSettings.PunishDuration):hh\\hmm\\m})**"; + + return GetText(strs.raid_stats(Format.Bold(stats.AntiRaidSettings.UserThreshold.ToString()), + Format.Bold(stats.AntiRaidSettings.Seconds.ToString()), + actionString)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/ProtectionService.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionService.cs new file mode 100644 index 0000000..c28d3c4 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionService.cs @@ -0,0 +1,499 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using System.Threading.Channels; + +namespace EllieBot.Modules.Administration.Services; + +public class ProtectionService : IEService +{ + public event Func OnAntiProtectionTriggered = delegate + { + return Task.CompletedTask; + }; + + private readonly ConcurrentDictionary _antiRaidGuilds = new(); + + private readonly ConcurrentDictionary _antiSpamGuilds = new(); + + private readonly ConcurrentDictionary _antiAltGuilds = new(); + + private readonly DiscordSocketClient _client; + private readonly MuteService _mute; + private readonly DbService _db; + private readonly UserPunishService _punishService; + + private readonly Channel _punishUserQueue = + Channel.CreateUnbounded(new() + { + SingleReader = true, + SingleWriter = false + }); + + public ProtectionService( + DiscordSocketClient client, + IBot bot, + MuteService mute, + DbService db, + UserPunishService punishService) + { + _client = client; + _mute = mute; + _db = db; + _punishService = punishService; + + var ids = client.GetGuildIds(); + using (var uow = db.GetDbContext()) + { + var configs = uow.Set() + .AsQueryable() + .Include(x => x.AntiRaidSetting) + .Include(x => x.AntiSpamSetting) + .ThenInclude(x => x.IgnoredChannels) + .Include(x => x.AntiAltSetting) + .Where(x => ids.Contains(x.GuildId)) + .ToList(); + + foreach (var gc in configs) + Initialize(gc); + } + + _client.MessageReceived += HandleAntiSpam; + _client.UserJoined += HandleUserJoined; + + bot.JoinedGuild += _bot_JoinedGuild; + _client.LeftGuild += _client_LeftGuild; + + _ = Task.Run(RunQueue); + } + + private async Task RunQueue() + { + while (true) + { + var item = await _punishUserQueue.Reader.ReadAsync(); + + var muteTime = item.MuteTime; + var gu = item.User; + try + { + await _punishService.ApplyPunishment(gu.Guild, + gu, + _client.CurrentUser, + item.Action, + muteTime, + item.RoleId, + $"{item.Type} Protection"); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in punish queue: {Message}", ex.Message); + } + finally + { + await Task.Delay(1000); + } + } + } + + private Task _client_LeftGuild(SocketGuild guild) + { + _ = Task.Run(async () => + { + TryStopAntiRaid(guild.Id); + TryStopAntiSpam(guild.Id); + await TryStopAntiAlt(guild.Id); + }); + return Task.CompletedTask; + } + + private Task _bot_JoinedGuild(GuildConfig gc) + { + using var uow = _db.GetDbContext(); + var gcWithData = uow.GuildConfigsForId(gc.GuildId, + set => set.Include(x => x.AntiRaidSetting) + .Include(x => x.AntiAltSetting) + .Include(x => x.AntiSpamSetting) + .ThenInclude(x => x.IgnoredChannels)); + + Initialize(gcWithData); + return Task.CompletedTask; + } + + private void Initialize(GuildConfig gc) + { + var raid = gc.AntiRaidSetting; + var spam = gc.AntiSpamSetting; + + if (raid is not null) + { + var raidStats = new AntiRaidStats + { + AntiRaidSettings = raid + }; + _antiRaidGuilds[gc.GuildId] = raidStats; + } + + if (spam is not null) + { + _antiSpamGuilds[gc.GuildId] = new() + { + AntiSpamSettings = spam + }; + } + + var alt = gc.AntiAltSetting; + if (alt is not null) + _antiAltGuilds[gc.GuildId] = new(alt); + } + + private Task HandleUserJoined(SocketGuildUser user) + { + if (user.IsBot) + return Task.CompletedTask; + + _antiRaidGuilds.TryGetValue(user.Guild.Id, out var maybeStats); + _antiAltGuilds.TryGetValue(user.Guild.Id, out var maybeAlts); + + if (maybeStats is null && maybeAlts is null) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + if (maybeAlts is { } alts) + { + if (user.CreatedAt != default) + { + var diff = DateTime.UtcNow - user.CreatedAt.UtcDateTime; + if (diff < alts.MinAge) + { + alts.Increment(); + + await PunishUsers(alts.Action, + ProtectionType.Alting, + alts.ActionDurationMinutes, + alts.RoleId, + user); + + return; + } + } + } + + try + { + if (maybeStats is not { } stats || !stats.RaidUsers.Add(user)) + return; + + ++stats.UsersCount; + + if (stats.UsersCount >= stats.AntiRaidSettings.UserThreshold) + { + var users = stats.RaidUsers.ToArray(); + stats.RaidUsers.Clear(); + var settings = stats.AntiRaidSettings; + + await PunishUsers(settings.Action, ProtectionType.Raiding, settings.PunishDuration, null, users); + } + + await Task.Delay(1000 * stats.AntiRaidSettings.Seconds); + + stats.RaidUsers.TryRemove(user); + --stats.UsersCount; + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task HandleAntiSpam(SocketMessage arg) + { + if (arg is not SocketUserMessage msg || msg.Author.IsBot) + return Task.CompletedTask; + + if (msg.Channel is not ITextChannel channel) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + if (!_antiSpamGuilds.TryGetValue(channel.Guild.Id, out var spamSettings) + || spamSettings.AntiSpamSettings.IgnoredChannels.Contains(new() + { + ChannelId = channel.Id + })) + return; + + var stats = spamSettings.UserStats.AddOrUpdate(msg.Author.Id, + _ => new(msg), + (_, old) => + { + old.ApplyNextMessage(msg); + return old; + }); + + if (stats.Count >= spamSettings.AntiSpamSettings.MessageThreshold) + { + if (spamSettings.UserStats.TryRemove(msg.Author.Id, out stats)) + { + var settings = spamSettings.AntiSpamSettings; + await PunishUsers(settings.Action, + ProtectionType.Spamming, + settings.MuteTime, + settings.RoleId, + (IGuildUser)msg.Author); + } + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private async Task PunishUsers( + PunishmentAction action, + ProtectionType pt, + int muteTime, + ulong? roleId, + params IGuildUser[] gus) + { + Log.Information("[{PunishType}] - Punishing [{Count}] users with [{PunishAction}] in {GuildName} guild", + pt, + gus.Length, + action, + gus[0].Guild.Name); + + foreach (var gu in gus) + { + await _punishUserQueue.Writer.WriteAsync(new() + { + Action = action, + Type = pt, + User = gu, + MuteTime = muteTime, + RoleId = roleId + }); + } + + _ = OnAntiProtectionTriggered(action, pt, gus); + } + + public async Task StartAntiRaidAsync( + ulong guildId, + int userThreshold, + int seconds, + PunishmentAction action, + int minutesDuration) + { + var g = _client.GetGuild(guildId); + await _mute.GetMuteRole(g); + + if (action == PunishmentAction.AddRole) + return null; + + if (!IsDurationAllowed(action)) + minutesDuration = 0; + + var stats = new AntiRaidStats + { + AntiRaidSettings = new() + { + Action = action, + Seconds = seconds, + UserThreshold = userThreshold, + PunishDuration = minutesDuration + } + }; + + _antiRaidGuilds.AddOrUpdate(guildId, stats, (_, _) => stats); + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting)); + + gc.AntiRaidSetting = stats.AntiRaidSettings; + await uow.SaveChangesAsync(); + + return stats; + } + + public bool TryStopAntiRaid(ulong guildId) + { + if (_antiRaidGuilds.TryRemove(guildId, out _)) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting)); + + gc.AntiRaidSetting = null; + uow.SaveChanges(); + return true; + } + + return false; + } + + public bool TryStopAntiSpam(ulong guildId) + { + if (_antiSpamGuilds.TryRemove(guildId, out _)) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels)); + + gc.AntiSpamSetting = null; + uow.SaveChanges(); + return true; + } + + return false; + } + + public async Task StartAntiSpamAsync( + ulong guildId, + int messageCount, + PunishmentAction action, + int punishDurationMinutes, + ulong? roleId) + { + var g = _client.GetGuild(guildId); + await _mute.GetMuteRole(g); + + if (!IsDurationAllowed(action)) + punishDurationMinutes = 0; + + var stats = new AntiSpamStats + { + AntiSpamSettings = new() + { + Action = action, + MessageThreshold = messageCount, + MuteTime = punishDurationMinutes, + RoleId = roleId + } + }; + + stats = _antiSpamGuilds.AddOrUpdate(guildId, + stats, + (_, old) => + { + stats.AntiSpamSettings.IgnoredChannels = old.AntiSpamSettings.IgnoredChannels; + return stats; + }); + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiSpamSetting)); + + if (gc.AntiSpamSetting is not null) + { + gc.AntiSpamSetting.Action = stats.AntiSpamSettings.Action; + gc.AntiSpamSetting.MessageThreshold = stats.AntiSpamSettings.MessageThreshold; + gc.AntiSpamSetting.MuteTime = stats.AntiSpamSettings.MuteTime; + gc.AntiSpamSetting.RoleId = stats.AntiSpamSettings.RoleId; + } + else + gc.AntiSpamSetting = stats.AntiSpamSettings; + + await uow.SaveChangesAsync(); + return stats; + } + + public async Task AntiSpamIgnoreAsync(ulong guildId, ulong channelId) + { + var obj = new AntiSpamIgnore + { + ChannelId = channelId + }; + bool added; + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels)); + var spam = gc.AntiSpamSetting; + if (spam is null) + return null; + + if (spam.IgnoredChannels.Add(obj)) // if adding to db is successful + { + if (_antiSpamGuilds.TryGetValue(guildId, out var temp)) + temp.AntiSpamSettings.IgnoredChannels.Add(obj); // add to local cache + + added = true; + } + else + { + var toRemove = spam.IgnoredChannels.First(x => x.ChannelId == channelId); + uow.Set().Remove(toRemove); // remove from db + if (_antiSpamGuilds.TryGetValue(guildId, out var temp)) + temp.AntiSpamSettings.IgnoredChannels.Remove(toRemove); // remove from local cache + + added = false; + } + + await uow.SaveChangesAsync(); + return added; + } + + public (AntiSpamStats, AntiRaidStats, AntiAltStats) GetAntiStats(ulong guildId) + { + _antiRaidGuilds.TryGetValue(guildId, out var antiRaidStats); + _antiSpamGuilds.TryGetValue(guildId, out var antiSpamStats); + _antiAltGuilds.TryGetValue(guildId, out var antiAltStats); + + return (antiSpamStats, antiRaidStats, antiAltStats); + } + + public bool IsDurationAllowed(PunishmentAction action) + { + switch (action) + { + case PunishmentAction.Ban: + case PunishmentAction.Mute: + case PunishmentAction.ChatMute: + case PunishmentAction.VoiceMute: + case PunishmentAction.AddRole: + case PunishmentAction.TimeOut: + return true; + default: + return false; + } + } + + public async Task StartAntiAltAsync( + ulong guildId, + int minAgeMinutes, + PunishmentAction action, + int actionDurationMinutes = 0, + ulong? roleId = null) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting)); + gc.AntiAltSetting = new() + { + Action = action, + ActionDurationMinutes = actionDurationMinutes, + MinAge = TimeSpan.FromMinutes(minAgeMinutes), + RoleId = roleId + }; + + await uow.SaveChangesAsync(); + _antiAltGuilds[guildId] = new(gc.AntiAltSetting); + } + + public async Task TryStopAntiAlt(ulong guildId) + { + if (!_antiAltGuilds.TryRemove(guildId, out _)) + return false; + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting)); + gc.AntiAltSetting = null; + await uow.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs new file mode 100644 index 0000000..f45db4e --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs @@ -0,0 +1,52 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public enum ProtectionType +{ + Raiding, + Spamming, + Alting +} + +public class AntiRaidStats +{ + public AntiRaidSetting AntiRaidSettings { get; set; } + public int UsersCount { get; set; } + public ConcurrentHashSet RaidUsers { get; set; } = new(); +} + +public class AntiSpamStats +{ + public AntiSpamSetting AntiSpamSettings { get; set; } + public ConcurrentDictionary UserStats { get; set; } = new(); +} + +public class AntiAltStats +{ + public PunishmentAction Action + => _setting.Action; + + public int ActionDurationMinutes + => _setting.ActionDurationMinutes; + + public ulong? RoleId + => _setting.RoleId; + + public TimeSpan MinAge + => _setting.MinAge; + + public int Counter + => counter; + + private readonly AntiAltSetting _setting; + + private int counter; + + public AntiAltStats(AntiAltSetting setting) + => _setting = setting; + + public void Increment() + => Interlocked.Increment(ref counter); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs b/src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs new file mode 100644 index 0000000..9cff02e --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs @@ -0,0 +1,13 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public class PunishQueueItem +{ + public PunishmentAction Action { get; set; } + public ProtectionType Type { get; set; } + public int MuteTime { get; set; } + public ulong? RoleId { get; set; } + public IGuildUser User { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs b/src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs new file mode 100644 index 0000000..ad4a9bf --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs @@ -0,0 +1,64 @@ +#nullable disable +namespace EllieBot.Modules.Administration; + +public sealed class UserSpamStats +{ + public int Count + { + get + { + lock (_applyLock) + { + Cleanup(); + return _messageTracker.Count; + } + } + } + + private string lastMessage; + + private readonly Queue _messageTracker; + + private readonly object _applyLock = new(); + + private readonly TimeSpan _maxTime = TimeSpan.FromMinutes(30); + + public UserSpamStats(IUserMessage msg) + { + lastMessage = msg.Content.ToUpperInvariant(); + _messageTracker = new(); + + ApplyNextMessage(msg); + } + + public void ApplyNextMessage(IUserMessage message) + { + var upperMsg = message.Content.ToUpperInvariant(); + + lock (_applyLock) + { + if (upperMsg != lastMessage || (string.IsNullOrWhiteSpace(upperMsg) && message.Attachments.Any())) + { + // if it's a new message, reset spam counter + lastMessage = upperMsg; + _messageTracker.Clear(); + } + + _messageTracker.Enqueue(DateTime.UtcNow); + } + } + + private void Cleanup() + { + lock (_applyLock) + { + while (_messageTracker.TryPeek(out var dateTime)) + { + if (DateTime.UtcNow - dateTime < _maxTime) + break; + + _messageTracker.Dequeue(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Prune/PruneCommands.cs b/src/EllieBot/Modules/Administration/Prune/PruneCommands.cs new file mode 100644 index 0000000..58b586e --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prune/PruneCommands.cs @@ -0,0 +1,198 @@ +#nullable disable +using CommandLine; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PruneCommands : EllieModule + { + private static readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14); + + public sealed class PruneOptions : IEllieCommandOptions + { + [Option(shortName: 's', + longName: "safe", + Default = false, + HelpText = "Whether pinned messages should be deleted.", + Required = false)] + public bool Safe { get; set; } + + [Option(shortName: 'a', + longName: "after", + Default = null, + HelpText = "Prune only messages after the specified message ID.", + Required = false)] + public ulong? After { get; set; } + + public void NormalizeOptions() + { + } + } + + //deletes her own messages, no perm required + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Prune(params string[] args) + { + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + var user = await ctx.Guild.GetCurrentUserAsync(); + + var progressMsg = await Response().Pending(strs.prune_progress(0, 100)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + if (opts.Safe) + await _service.PruneWhere((ITextChannel)ctx.Channel, + 100, + x => x.Author.Id == user.Id && !x.IsPinned, + progress, + opts.After); + else + await _service.PruneWhere((ITextChannel)ctx.Channel, + 100, + x => x.Author.Id == user.Id, + progress, + opts.After); + + ctx.Message.DeleteAfter(3); + await progressMsg.DeleteAsync(); + } + + // prune x + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(1)] + public async Task Prune(int count, params string[] args) + { + count++; + if (count < 1) + return; + + if (count > 1000) + count = 1000; + + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + if (opts.Safe) + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + x => !x.IsPinned && x.Id != progressMsg.Id, + progress, + opts.After); + else + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + x => x.Id != progressMsg.Id, + progress, + opts.After); + + await progressMsg.DeleteAsync(); + } + + private IProgress<(int, int)> GetProgressTracker(IUserMessage progressMsg) + { + var progress = new Progress<(int, int)>(async (x) => + { + var (deleted, total) = x; + try + { + await progressMsg.ModifyAsync(props => + { + props.Embed = _sender.CreateEmbed() + .WithPendingColor() + .WithDescription(GetText(strs.prune_progress(deleted, total))) + .Build(); + }); + } + catch + { + } + }); + + return progress; + } + + //prune @user [x] + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(0)] + public Task Prune(IGuildUser user, int count = 100, params string[] args) + => Prune(user.Id, count, args); + + //prune userid [x] + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(0)] + public async Task Prune(ulong userId, int count = 100, params string[] args) + { + if (userId == ctx.User.Id) + count++; + + if (count < 1) + return; + + if (count > 1000) + count = 1000; + + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + if (opts.Safe) + { + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks && !m.IsPinned, + progress, + opts.After + ); + } + else + { + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks, + progress, + opts.After + ); + } + + await progressMsg.DeleteAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + public async Task PruneCancel() + { + var ok = await _service.CancelAsync(ctx.Guild.Id); + + if (!ok) + { + await Response().Error(strs.prune_not_found).SendAsync(); + return; + } + + + await Response().Confirm(strs.prune_cancelled).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Prune/PruneService.cs b/src/EllieBot/Modules/Administration/Prune/PruneService.cs new file mode 100644 index 0000000..006f9e8 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prune/PruneService.cs @@ -0,0 +1,101 @@ +#nullable disable +namespace EllieBot.Modules.Administration.Services; + +public class PruneService : IEService +{ + //channelids where prunes are currently occuring + private readonly ConcurrentDictionary _pruningGuilds = new(); + private readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14); + private readonly ILogCommandService _logService; + + public PruneService(ILogCommandService logService) + => _logService = logService; + + public async Task PruneWhere( + ITextChannel channel, + int amount, + Func predicate, + IProgress<(int deleted, int total)> progress, + ulong? after = null + ) + { + ArgumentNullException.ThrowIfNull(channel, nameof(channel)); + + var originalAmount = amount; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + using var cancelSource = new CancellationTokenSource(); + if (!_pruningGuilds.TryAdd(channel.GuildId, cancelSource)) + return; + + try + { + var now = DateTime.UtcNow; + IMessage[] msgs; + IMessage lastMessage = null; + + while (amount > 0 && !cancelSource.IsCancellationRequested) + { + var dled = lastMessage is null + ? await channel.GetMessagesAsync(50).FlattenAsync() + : await channel.GetMessagesAsync(lastMessage, Direction.Before, 50).FlattenAsync(); + + msgs = dled + .Where(predicate) + .Where(x => after is not ulong a || x.Id > a) + .Take(amount) + .ToArray(); + + if (!msgs.Any()) + return; + + lastMessage = msgs[^1]; + + var bulkDeletable = new List(); + var singleDeletable = new List(); + foreach (var x in msgs) + { + _logService.AddDeleteIgnore(x.Id); + + if (now - x.CreatedAt < _twoWeeks) + bulkDeletable.Add(x); + else + singleDeletable.Add(x); + } + + if (bulkDeletable.Count > 0) + { + await channel.DeleteMessagesAsync(bulkDeletable); + amount -= msgs.Length; + progress.Report((originalAmount - amount, originalAmount)); + await Task.Delay(2000, cancelSource.Token); + } + + foreach (var group in singleDeletable.Chunk(5)) + { + await group.Select(x => x.DeleteAsync()).WhenAll(); + amount -= 5; + progress.Report((originalAmount - amount, originalAmount)); + await Task.Delay(5000, cancelSource.Token); + } + } + } + catch + { + //ignore + } + finally + { + _pruningGuilds.TryRemove(channel.GuildId, out _); + } + } + + public async Task CancelAsync(ulong guildId) + { + if (!_pruningGuilds.TryRemove(guildId, out var source)) + return false; + + await source.CancelAsync(); + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs b/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs new file mode 100644 index 0000000..85f7945 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs @@ -0,0 +1,52 @@ +#nullable disable +using EllieBot.Modules.Patronage; +using EllieBot.Db.Models; +using OneOf; +using OneOf.Types; + +namespace EllieBot.Modules.Administration.Services; + +public interface IReactionRoleService +{ + /// + /// Adds a single reaction role + /// + /// Guild where to add a reaction role + /// Message to which to add a reaction role + /// + /// + /// + /// + /// The result of the operation + Task> AddReactionRole( + IGuild guild, + IMessage msg, + string emote, + IRole role, + int group = 0, + int levelReq = 0); + + /// + /// Get all reaction roles on the specified server + /// + /// + /// + Task> GetReactionRolesAsync(ulong guildId); + + /// + /// Remove reaction roles on the specified message + /// + /// + /// + /// + Task RemoveReactionRoles(ulong guildId, ulong messageId); + + /// + /// Remove all reaction roles in the specified server + /// + /// + /// + Task RemoveAllReactionRoles(ulong guildId); + + Task> TransferReactionRolesAsync(ulong guildId, ulong fromMessageId, ulong toMessageId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs b/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs new file mode 100644 index 0000000..800512a --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs @@ -0,0 +1,176 @@ +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + public partial class ReactionRoleCommands : EllieModule + { + private readonly IReactionRoleService _rero; + + public ReactionRoleCommands(IReactionRoleService rero) + { + _rero = rero; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoAdd( + ulong messageId, + string emoteStr, + IRole role, + int group = 0, + int levelReq = 0) + { + if (group < 0) + return; + + if (levelReq < 0) + return; + + var msg = await ctx.Channel.GetMessageAsync(messageId); + if (msg is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + if (ctx.User.Id != ctx.Guild.OwnerId + && ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position) <= role.Position) + { + await Response().Error(strs.hierarchy).SendAsync(); + return; + } + + var emote = emoteStr.ToIEmote(); + await msg.AddReactionAsync(emote); + var res = await _rero.AddReactionRole(ctx.Guild, + msg, + emoteStr, + role, + group, + levelReq); + + await res.Match( + _ => ctx.OkAsync(), + fl => + { + _ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser); + return !fl.IsPatronLimit + ? Response().Error(strs.limit_reached(fl.Quota)).SendAsync() + : Response().Pending(strs.feature_limit_reached_owner(fl.Quota, fl.Name)).SendAsync(); + }); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoList(int page = 1) + { + if (--page < 0) + return; + + var allReros = await _rero.GetReactionRolesAsync(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allReros.OrderBy(x => x.Group).ToList()) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => + { + var embed = _sender.CreateEmbed() + .WithOkColor(); + + var content = string.Empty; + foreach (var g in items + .GroupBy(x => x.MessageId) + .OrderBy(x => x.Key)) + { + var messageId = g.Key; + content += + $"[{messageId}](https://discord.com/channels/{ctx.Guild.Id}/{g.First().ChannelId}/{g.Key})\n"; + + var groupGroups = g.GroupBy(x => x.Group); + + foreach (var ggs in groupGroups) + { + content += $"`< {(g.Key == 0 ? ("Not Exclusive (Group 0)") : ($"Group {ggs.Key}"))} >`\n"; + + foreach (var rero in ggs) + { + content += + $"\t{rero.Emote} -> {(ctx.Guild.GetRole(rero.RoleId)?.Mention ?? "")}"; + if (rero.LevelReq > 0) + content += $" (lvl {rero.LevelReq}+)"; + content += '\n'; + } + } + } + + embed.WithDescription(string.IsNullOrWhiteSpace(content) + ? "There are no reaction roles on this server" + : content); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoRemove(ulong messageId) + { + var succ = await _rero.RemoveReactionRoles(ctx.Guild.Id, messageId); + if (succ) + await ctx.OkAsync(); + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoDeleteAll() + { + await _rero.RemoveAllReactionRoles(ctx.Guild.Id); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Ratelimit(60)] + public async Task ReRoTransfer(ulong fromMessageId, ulong toMessageId) + { + var msg = await ctx.Channel.GetMessageAsync(toMessageId); + + if (msg is null) + { + await ctx.ErrorAsync(); + return; + } + + var reactions = await _rero.TransferReactionRolesAsync(ctx.Guild.Id, fromMessageId, toMessageId); + + if (reactions.Count == 0) + { + await ctx.ErrorAsync(); + } + else + { + foreach (var r in reactions) + { + await msg.AddReactionAsync(r); + } + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs b/src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs new file mode 100644 index 0000000..f1216ef --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs @@ -0,0 +1,408 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Modules.Patronage; +using EllieBot.Db.Models; +using OneOf.Types; +using OneOf; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class ReactionRolesService : IReadyExecutor, IEService, IReactionRoleService +{ + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + + private ConcurrentDictionary> _cache; + private readonly object _cacheLock = new(); + private readonly SemaphoreSlim _assignementLock = new(1, 1); + private readonly IPatronageService _ps; + + private static readonly FeatureLimitKey _reroFLKey = new() + { + Key = "rero:max_count", + PrettyName = "Reaction Role" + }; + + public ReactionRolesService( + DiscordSocketClient client, + DbService db, + IBotCredentials creds, + IPatronageService ps) + { + _db = db; + _ps = ps; + _client = client; + _creds = creds; + _cache = new(); + } + + public async Task OnReadyAsync() + { + await using var uow = _db.GetDbContext(); + var reros = await uow.GetTable() + .Where( + x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId)) + .ToListAsyncLinqToDB(); + + foreach (var group in reros.GroupBy(x => x.MessageId)) + { + _cache[group.Key] = group.ToList(); + } + + _client.ReactionAdded += ClientOnReactionAdded; + _client.ReactionRemoved += ClientOnReactionRemoved; + } + + private async Task<(IGuildUser, IRole)> GetUserAndRoleAsync( + ulong userId, + ReactionRoleV2 rero) + { + var guild = _client.GetGuild(rero.GuildId); + var role = guild?.GetRole(rero.RoleId); + + if (role is null) + return default; + + var user = guild.GetUser(userId) as IGuildUser + ?? await _client.Rest.GetGuildUserAsync(guild.Id, userId); + + if (user is null) + return default; + + return (user, role); + } + + private Task ClientOnReactionRemoved( + Cacheable cmsg, + Cacheable ch, + SocketReaction r) + { + if (!_cache.TryGetValue(cmsg.Id, out var reros)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var emote = await GetFixedEmoteAsync(cmsg, r.Emote); + + var rero = reros.FirstOrDefault(x => x.Emote == emote.Name + || x.Emote == emote.ToString()); + if (rero is null) + return; + + var (user, role) = await GetUserAndRoleAsync(r.UserId, rero); + + if (user.IsBot) + return; + + await _assignementLock.WaitAsync(); + try + { + if (user.RoleIds.Contains(role.Id)) + { + await user.RemoveRoleAsync(role.Id, new RequestOptions() + { + AuditLogReason = $"Reaction role" + }); + } + } + finally + { + _assignementLock.Release(); + } + }); + + return Task.CompletedTask; + } + + + // had to add this because for some reason, reactionremoved event's reaction doesn't have IsAnimated set, + // causing the .ToString() to be wrong on animated custom emotes + private async Task GetFixedEmoteAsync( + Cacheable cmsg, + IEmote inputEmote) + { + // this should only run for emote + if (inputEmote is not Emote e) + return inputEmote; + + // try to get the message and pull + var msg = await cmsg.GetOrDownloadAsync(); + + var emote = msg.Reactions.Keys.FirstOrDefault(x => e.Equals(x)); + return emote ?? inputEmote; + } + + private Task ClientOnReactionAdded( + Cacheable msg, + Cacheable ch, + SocketReaction r) + { + if (!_cache.TryGetValue(msg.Id, out var reros)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var rero = reros.FirstOrDefault(x => x.Emote == r.Emote.Name || x.Emote == r.Emote.ToString()); + if (rero is null) + return; + + var (user, role) = await GetUserAndRoleAsync(r.UserId, rero); + + if (user.IsBot) + return; + + await _assignementLock.WaitAsync(); + try + { + if (!user.RoleIds.Contains(role.Id)) + { + // first check if there is a level requirement + // and if there is, make sure user satisfies it + if (rero.LevelReq > 0) + { + await using var ctx = _db.GetDbContext(); + var levelData = await ctx.GetTable() + .GetLevelDataFor(user.GuildId, user.Id); + + if (levelData.Level < rero.LevelReq) + return; + } + + // remove all other roles from the same group from the user + // execept in group 0, which is a special, non-exclusive group + if (rero.Group != 0) + { + var exclusive = reros + .Where(x => x.Group == rero.Group && x.RoleId != role.Id) + .Select(x => x.RoleId) + .Distinct() + .ToArray(); + + + if (exclusive.Any()) + { + try + { + await user.RemoveRolesAsync(exclusive, + new RequestOptions() + { + AuditLogReason = "Reaction role exclusive group" + }); + } + catch { } + } + + // remove user's previous reaction + try + { + var m = await msg.GetOrDownloadAsync(); + if (m is not null) + { + var reactToRemove = m.Reactions + .FirstOrDefault(x => x.Key.ToString() != r.Emote.ToString()) + .Key; + + if (reactToRemove is not null) + { + await m.RemoveReactionAsync(reactToRemove, user); + } + } + } + catch + { + } + } + + await user.AddRoleAsync(role.Id, new() + { + AuditLogReason = "Reaction role" + }); + } + } + finally + { + _assignementLock.Release(); + } + }); + + return Task.CompletedTask; + } + + /// + /// Adds a single reaction role + /// + /// Guild where to add a reaction role + /// Message to which to add a reaction role + /// + /// + /// + /// + /// The result of the operation + public async Task> AddReactionRole( + IGuild guild, + IMessage msg, + string emote, + IRole role, + int group = 0, + int levelReq = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(group); + + ArgumentOutOfRangeException.ThrowIfNegative(levelReq); + + await using var ctx = _db.GetDbContext(); + + await using var tran = await ctx.Database.BeginTransactionAsync(); + var activeReactionRoles = await ctx.GetTable() + .Where(x => x.GuildId == guild.Id) + .CountAsync(); + + var result = await _ps.TryGetFeatureLimitAsync(_reroFLKey, guild.OwnerId, 50); + if (result.Quota != -1 && activeReactionRoles >= result.Quota) + return result; + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guild.Id, + ChannelId = msg.Channel.Id, + + MessageId = msg.Id, + Emote = emote, + + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }, + (old) => new() + { + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }, + () => new() + { + MessageId = msg.Id, + Emote = emote, + }); + + await tran.CommitAsync(); + + var obj = new ReactionRoleV2() + { + GuildId = guild.Id, + MessageId = msg.Id, + Emote = emote, + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }; + + lock (_cacheLock) + { + _cache.AddOrUpdate(msg.Id, + _ => [obj], + (_, list) => + { + list.RemoveAll(x => x.Emote == emote); + list.Add(obj); + return list; + }); + } + + return new Success(); + } + + /// + /// Get all reaction roles on the specified server + /// + /// + /// + public async Task> GetReactionRolesAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + } + + /// + /// Remove reaction roles on the specified message + /// + /// + /// + /// + public async Task RemoveReactionRoles(ulong guildId, ulong messageId) + { + // guildid is used for quick index lookup + await using var ctx = _db.GetDbContext(); + var changed = await ctx.GetTable() + .Where(x => x.GuildId == guildId && x.MessageId == messageId) + .DeleteAsync(); + + _cache.TryRemove(messageId, out _); + + if (changed == 0) + return false; + + return true; + } + + /// + /// Remove all reaction roles in the specified server + /// + /// + /// + public async Task RemoveAllReactionRoles(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + var output = await ctx.GetTable() + .Where(x => x.GuildId == guildId) + .DeleteWithOutputAsync(x => x.MessageId); + + lock (_cacheLock) + { + foreach (var o in output) + { + _cache.TryRemove(o, out _); + } + } + + return output.Length; + } + + public async Task> TransferReactionRolesAsync( + ulong guildId, + ulong fromMessageId, + ulong toMessageId) + { + await using var ctx = _db.GetDbContext(); + var updated = ctx.GetTable() + .Where(x => x.GuildId == guildId && x.MessageId == fromMessageId) + .UpdateWithOutput(old => new() + { + MessageId = toMessageId + }, + (old, neu) => neu); + lock (_cacheLock) + { + if (_cache.TryRemove(fromMessageId, out var data)) + { + if (_cache.TryGetValue(toMessageId, out var newData)) + { + newData.AddRange(data); + } + else + { + _cache[toMessageId] = data; + } + } + } + + return updated.Select(x => x.Emote.ToIEmote()).ToList(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/RoleCommands.cs b/src/EllieBot/Modules/Administration/Role/RoleCommands.cs new file mode 100644 index 0000000..7f5daea --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/RoleCommands.cs @@ -0,0 +1,209 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + public partial class RoleCommands : EllieModule + { + public enum Exclude + { + Excl + } + + private readonly IServiceProvider _services; + private StickyRolesService _stickyRoleSvc; + + public RoleCommands(IServiceProvider services, StickyRolesService stickyRoleSvc) + { + _services = services; + _stickyRoleSvc = stickyRoleSvc; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task SetRole(IGuildUser targetUser, [Leftover] IRole roleToAdd) + { + var runnerUser = (IGuildUser)ctx.User; + var runnerMaxRolePosition = runnerUser.GetRoles().Max(x => x.Position); + if (ctx.User.Id != ctx.Guild.OwnerId && runnerMaxRolePosition <= roleToAdd.Position) + return; + try + { + await targetUser.AddRoleAsync(roleToAdd, new RequestOptions() + { + AuditLogReason = $"Added by [{ctx.User.Username}]" + }); + + await Response().Confirm(strs.setrole(Format.Bold(roleToAdd.Name), + Format.Bold(targetUser.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in setrole command"); + await Response().Error(strs.setrole_err).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RemoveRole(IGuildUser targetUser, [Leftover] IRole roleToRemove) + { + var runnerUser = (IGuildUser)ctx.User; + if (ctx.User.Id != runnerUser.Guild.OwnerId + && runnerUser.GetRoles().Max(x => x.Position) <= roleToRemove.Position) + return; + try + { + await targetUser.RemoveRoleAsync(roleToRemove); + await Response().Confirm(strs.remrole(Format.Bold(roleToRemove.Name), + Format.Bold(targetUser.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.remrole_err).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RenameRole(IRole roleToEdit, [Leftover] string newname) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= roleToEdit.Position) + return; + try + { + if (roleToEdit.Position > (await ctx.Guild.GetCurrentUserAsync()).GetRoles().Max(r => r.Position)) + { + await Response().Error(strs.renrole_perms).SendAsync(); + return; + } + + await roleToEdit.ModifyAsync(g => g.Name = newname); + await Response().Confirm(strs.renrole).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.renrole_err).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RemoveAllRoles([Leftover] IGuildUser user) + { + var guser = (IGuildUser)ctx.User; + + var userRoles = user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole).ToList(); + + if (user.Id == ctx.Guild.OwnerId + || (ctx.User.Id != ctx.Guild.OwnerId + && guser.GetRoles().Max(x => x.Position) <= userRoles.Max(x => x.Position))) + return; + try + { + await user.RemoveRolesAsync(userRoles); + await Response().Confirm(strs.rar(Format.Bold(user.ToString()))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.rar_err).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task CreateRole([Leftover] string roleName = null) + { + if (string.IsNullOrWhiteSpace(roleName)) + return; + + var r = await ctx.Guild.CreateRoleAsync(roleName, isMentionable: false); + await Response().Confirm(strs.cr(Format.Bold(r.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task DeleteRole([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + await role.DeleteAsync(); + await Response().Confirm(strs.dr(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RoleHoist([Leftover] IRole role) + { + var newHoisted = !role.IsHoisted; + await role.ModifyAsync(r => r.Hoist = newHoisted); + if (newHoisted) + await Response().Confirm(strs.rolehoist_enabled(Format.Bold(role.Name))).SendAsync(); + else + await Response().Confirm(strs.rolehoist_disabled(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task RoleColor([Leftover] IRole role) + => await Response().Confirm("Role Color", role.Color.RawValue.ToString("x6")).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task RoleColor(Color color, [Leftover] IRole role) + { + try + { + var rgba32 = color.ToPixel(); + await role.ModifyAsync(r => r.Color = new Discord.Color(rgba32.R, rgba32.G, rgba32.B)); + await Response().Confirm(strs.rc(Format.Bold(role.Name))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.rc_perms).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task StickyRoles() + { + var newState = await _stickyRoleSvc.ToggleStickyRoles(ctx.Guild.Id); + + if (newState) + { + await Response().Confirm(strs.sticky_roles_enabled).SendAsync(); + } + else + { + await Response().Confirm(strs.sticky_roles_disabled).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs b/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs new file mode 100644 index 0000000..1fcfc15 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs @@ -0,0 +1,139 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; + +namespace EllieBot.Modules.Administration; + +public sealed class StickyRolesService : IEService, IReadyExecutor +{ + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly DbService _db; + private HashSet _stickyRoles = new(); + + public StickyRolesService( + DiscordSocketClient client, + IBotCredentials creds, + DbService db) + { + _client = client; + _creds = creds; + _db = db; + } + + + public async Task OnReadyAsync() + { + await using (var ctx = _db.GetDbContext()) + { + _stickyRoles = (await ctx + .Set() + .ToLinqToDBTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, + _creds.TotalShards, + _client.ShardId)) + .Where(x => x.StickyRoles) + .Select(x => x.GuildId) + .ToListAsync()) + .ToHashSet(); + } + + _client.UserJoined += ClientOnUserJoined; + _client.UserLeft += ClientOnUserLeft; + + // cleanup old ones every hour + // 30 days retention + if (_client.ShardId == 0) + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .Where(x => x.DateAdded < DateTime.UtcNow - TimeSpan.FromDays(30)) + .DeleteAsync(); + } + } + } + + private Task ClientOnUserLeft(SocketGuild guild, SocketUser user) + { + if (user is not SocketGuildUser gu) + return Task.CompletedTask; + + if (!_stickyRoles.Contains(guild.Id)) + return Task.CompletedTask; + + _ = Task.Run(async () => await SaveRolesAsync(guild.Id, gu.Id, gu.Roles)); + + return Task.CompletedTask; + } + + private async Task SaveRolesAsync(ulong guildId, ulong userId, IReadOnlyCollection guRoles) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertAsync(() => new() + { + GuildId = guildId, + UserId = userId, + RoleIds = string.Join(',', + guRoles.Where(x => !x.IsEveryone && !x.IsManaged).Select(x => x.Id.ToString())), + DateAdded = DateTime.UtcNow + }); + } + + private Task ClientOnUserJoined(SocketGuildUser user) + { + if (!_stickyRoles.Contains(user.Guild.Id)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var roles = await GetRolesAsync(user.Guild.Id, user.Id); + + await user.AddRolesAsync(roles); + }); + + return Task.CompletedTask; + } + + private async Task GetRolesAsync(ulong guildId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + var stickyRolesEntry = await ctx + .GetTable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .DeleteWithOutputAsync(); + + if (stickyRolesEntry is { Length: > 0 }) + { + return stickyRolesEntry[0].GetRoleIds(); + } + + return []; + } + + public async Task ToggleStickyRoles(ulong guildId, bool? newState = null) + { + await using var ctx = _db.GetDbContext(); + var config = ctx.GuildConfigsForId(guildId, set => set); + + config.StickyRoles = newState ?? !config.StickyRoles; + await ctx.SaveChangesAsync(); + + if (config.StickyRoles) + { + _stickyRoles.Add(guildId); + } + else + { + _stickyRoles.Remove(guildId); + } + + return config.StickyRoles; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs b/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs new file mode 100644 index 0000000..8bcf2e1 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs @@ -0,0 +1,169 @@ +using System.Net.Http.Json; +using System.Text; +using EllieBot.Common.ModuleBehaviors; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Administration.Self; + +public sealed class ToastielabReleaseModel +{ + [JsonPropertyName("tag_name")] + public required string TagName { get; init; } +} +public sealed class CheckForUpdatesService : IEService, IReadyExecutor +{ + private readonly BotConfigService _bcs; + private readonly IBotCredsProvider _bcp; + private readonly IHttpClientFactory _httpFactory; + private readonly DiscordSocketClient _client; + private readonly IMessageSenderService _sender; + + + private const string RELEASES_URL = "https://toastielab.dev/api/v1/repos/Emotions-stuff/Ellie/releases"; + + public CheckForUpdatesService( + BotConfigService bcs, + IBotCredsProvider bcp, + IHttpClientFactory httpFactory, + DiscordSocketClient client, + IMessageSenderService sender) + { + _bcs = bcs; + _bcp = bcp; + _httpFactory = httpFactory; + _client = client; + _sender = sender; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + var conf = _bcs.Data; + + if (!conf.CheckForUpdates) + continue; + + try + { + using var http = _httpFactory.CreateClient(); + var gitlabRelease = (await http.GetFromJsonAsync(RELEASES_URL)) + ?.FirstOrDefault(); + + if (gitlabRelease?.TagName is null) + continue; + + var latest = gitlabRelease.TagName; + var latestVersion = Version.Parse(latest); + var lastKnownVersion = GetLastKnownVersion(); + + if (lastKnownVersion is null) + { + UpdateLastKnownVersion(latestVersion); + continue; + } + + if (latestVersion > lastKnownVersion) + { + UpdateLastKnownVersion(latestVersion); + + // pull changelog + var changelog = await http.GetStringAsync("https://toastielab.dev/Emotions-stuff/Ellie/raw/branch/main/CHANGELOG.md"); + + var thisVersionChangelog = GetVersionChangelog(latestVersion, changelog); + + if (string.IsNullOrWhiteSpace(thisVersionChangelog)) + { + Log.Warning("New version {BotVersion} was found but changelog is unavailable", + thisVersionChangelog); + continue; + } + + var creds = _bcp.GetCreds(); + await creds.OwnerIds + .Select(async x => + { + var user = await _client.GetUserAsync(x); + if (user is null) + return; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor($"EllieBot v{latest} Released!") + .WithTitle("Changelog") + .WithUrl("https://toastielab.dev/Emotions-stuff/Ellie/src/branch/main/CHANGELOG.md") + .WithDescription(thisVersionChangelog.TrimTo(4096)) + .WithFooter( + "You may disable these messages by typing '.conf bot checkforupdates false'"); + + await _sender.Response(user).Embed(eb).SendAsync(); + }) + .WhenAll(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error while checking for new bot release: {ErrorMessage}", ex.Message); + } + } + } + + private string? GetVersionChangelog(Version latestVersion, string changelog) + { + var clSpan = changelog.AsSpan(); + + var sb = new StringBuilder(); + var started = false; + foreach (var line in clSpan.EnumerateLines()) + { + // if we're at the current version, keep reading lines and adding to the output + if (started) + { + // if we got to previous version, end + if (line.StartsWith("## [")) + break; + + // if we're reading a new segment, reformat it to print it better to discord + if (line.StartsWith("### ")) + { + sb.AppendLine(Format.Bold(line.ToString())); + } + else + { + sb.AppendLine(line.ToString()); + } + + continue; + } + + if (line.StartsWith($"## [{latestVersion.ToString()}]")) + { + started = true; + continue; + } + } + + return sb.ToString(); + } + + private const string LAST_KNOWN_VERSION_PATH = "data/last_known_version.txt"; + + private Version? GetLastKnownVersion() + { + if (!File.Exists(LAST_KNOWN_VERSION_PATH)) + return null; + + return Version.TryParse(File.ReadAllText(LAST_KNOWN_VERSION_PATH), out var ver) + ? ver + : null; + } + + private void UpdateLastKnownVersion(Version version) + { + File.WriteAllText("data/last_known_version.txt", version.ToString()); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Self/SelfCommands.cs b/src/EllieBot/Modules/Administration/Self/SelfCommands.cs new file mode 100644 index 0000000..d67ac93 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/SelfCommands.cs @@ -0,0 +1,586 @@ +#nullable disable +using Discord.Rest; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; +using Ellie.Common.Marmalade; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class SelfCommands : EllieModule + { + public enum SettableUserStatus + { + Online, + Invisible, + Idle, + Dnd + } + + private readonly DiscordSocketClient _client; + private readonly IBotStrings _strings; + private readonly IMarmaladeLoaderService _marmaladeLoader; + private readonly ICoordinator _coord; + private readonly DbService _db; + + public SelfCommands( + DiscordSocketClient client, + DbService db, + IBotStrings strings, + ICoordinator coord, + IMarmaladeLoaderService marmaladeLoader) + { + _client = client; + _db = db; + _strings = strings; + _coord = coord; + _marmaladeLoader = marmaladeLoader; + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public Task CacheUsers() + => CacheUsers(ctx.Guild); + + [Cmd] + [OwnerOnly] + public async Task CacheUsers(IGuild guild) + { + var downloadUsersTask = guild.DownloadUsersAsync(); + var message = await Response().Pending(strs.cache_users_pending).SendAsync(); + + await downloadUsersTask; + + var users = (await guild.GetUsersAsync(CacheMode.CacheOnly)) + .Cast() + .ToList(); + + var (added, updated) = await _service.RefreshUsersAsync(users); + + await message.ModifyAsync(x => + x.Embed = _sender.CreateEmbed() + .WithDescription(GetText(strs.cache_users_done(added, updated))) + .WithOkColor() + .Build() + ); + } + + [Cmd] + [OwnerOnly] + public async Task DoAs(IUser user, [Leftover] string message) + { + if (ctx.User is not IGuildUser { GuildPermissions.Administrator: true }) + return; + + if (ctx.Guild is SocketGuild sg + && ctx.Channel is ISocketMessageChannel ch + && ctx.Message is SocketUserMessage msg) + { + var fakeMessage = new DoAsUserMessage(msg, user, message); + + + await _cmdHandler.TryRunCommand(sg, ch, fakeMessage); + } + else + { + await Response().Error(strs.error_occured).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task StartupCommandAdd([Leftover] string cmdText) + { + if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + + var guser = (IGuildUser)ctx.User; + var cmd = new AutoCommand + { + CommandText = cmdText, + ChannelId = ctx.Channel.Id, + ChannelName = ctx.Channel.Name, + GuildId = ctx.Guild?.Id, + GuildName = ctx.Guild?.Name, + VoiceChannelId = guser.VoiceChannel?.Id, + VoiceChannelName = guser.VoiceChannel?.Name, + Interval = 0 + }; + _service.AddNewAutoCommand(cmd); + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.scadd)) + .AddField(GetText(strs.server), + cmd.GuildId is null ? "-" : $"{cmd.GuildName}/{cmd.GuildId}", + true) + .AddField(GetText(strs.channel), $"{cmd.ChannelName}/{cmd.ChannelId}", true) + .AddField(GetText(strs.command_text), cmdText)) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task AutoCommandAdd(int interval, [Leftover] string cmdText) + { + if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + + if (interval < 5) + return; + + var guser = (IGuildUser)ctx.User; + var cmd = new AutoCommand + { + CommandText = cmdText, + ChannelId = ctx.Channel.Id, + ChannelName = ctx.Channel.Name, + GuildId = ctx.Guild?.Id, + GuildName = ctx.Guild?.Name, + VoiceChannelId = guser.VoiceChannel?.Id, + VoiceChannelName = guser.VoiceChannel?.Name, + Interval = interval + }; + _service.AddNewAutoCommand(cmd); + + await Response().Confirm(strs.autocmd_add(Format.Code(Format.Sanitize(cmdText)), cmd.Interval)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task StartupCommandsList(int page = 1) + { + if (page-- < 1) + return; + + var scmds = _service.GetStartupCommands().Skip(page * 5).Take(5).ToList(); + + if (scmds.Count == 0) + await Response().Error(strs.startcmdlist_none).SendAsync(); + else + { + var i = 0; + await Response() + .Confirm(text: string.Join("\n", + scmds.Select(x => $@"```css +#{++i + (page * 5)} +[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")} +[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId} +[{GetText(strs.command_text)}]: {x.CommandText}```")), + title: string.Empty, + footer: GetText(strs.page(page + 1))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task AutoCommandsList(int page = 1) + { + if (page-- < 1) + return; + + var scmds = _service.GetAutoCommands().Skip(page * 5).Take(5).ToList(); + if (!scmds.Any()) + await Response().Error(strs.autocmdlist_none).SendAsync(); + else + { + var i = 0; + await Response() + .Confirm(text: string.Join("\n", + scmds.Select(x => $@"```css +#{++i + (page * 5)} +[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")} +[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId} +{GetIntervalText(x.Interval)} +[{GetText(strs.command_text)}]: {x.CommandText}```")), + title: string.Empty, + footer: GetText(strs.page(page + 1))) + .SendAsync(); + } + } + + private string GetIntervalText(int interval) + => $"[{GetText(strs.interval)}]: {interval}"; + + [Cmd] + [OwnerOnly] + public async Task Wait(int miliseconds) + { + if (miliseconds <= 0) + return; + ctx.Message.DeleteAfter(0); + try + { + var msg = await Response().Confirm($"⏲ {miliseconds}ms").SendAsync(); + msg.DeleteAfter(miliseconds / 1000); + } + catch { } + + await Task.Delay(miliseconds); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task AutoCommandRemove([Leftover] int index) + { + if (!_service.RemoveAutoCommand(--index, out _)) + { + await Response().Error(strs.acrm_fail).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task StartupCommandRemove([Leftover] int index) + { + if (!_service.RemoveStartupCommand(--index, out _)) + await Response().Error(strs.scrm_fail).SendAsync(); + else + await Response().Confirm(strs.scrm).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task StartupCommandsClear() + { + _service.ClearStartupCommands(); + + await Response().Confirm(strs.startcmds_cleared).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ForwardMessages() + { + var enabled = _service.ForwardMessages(); + + if (enabled) + await Response().Confirm(strs.fwdm_start).SendAsync(); + else + await Response().Pending(strs.fwdm_stop).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ForwardToAll() + { + var enabled = _service.ForwardToAll(); + + if (enabled) + await Response().Confirm(strs.fwall_start).SendAsync(); + else + await Response().Pending(strs.fwall_stop).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task ForwardToChannel() + { + var enabled = _service.ForwardToChannel(ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.fwch_start).SendAsync(); + else + await Response().Pending(strs.fwch_stop).SendAsync(); + } + + [Cmd] + public async Task ShardStats(int page = 1) + { + if (--page < 0) + return; + + var statuses = _coord.GetAllShardStatuses(); + + var status = string.Join(" : ", + statuses.Select(x => (ConnectionStateToEmoji(x), x)) + .GroupBy(x => x.Item1) + .Select(x => $"`{x.Count()} {x.Key}`") + .ToArray()); + + var allShardStrings = statuses.Select(st => + { + var timeDiff = DateTime.UtcNow - st.LastUpdate; + var stateStr = ConnectionStateToEmoji(st); + var maxGuildCountLength = + statuses.Max(x => x.GuildCount).ToString().Length; + return $"`{stateStr} " + + $"| #{st.ShardId.ToString().PadBoth(3)} " + + $"| {timeDiff:mm\\:ss} " + + $"| {st.GuildCount.ToString().PadBoth(maxGuildCountLength)} `"; + }) + .ToArray(); + await Response() + .Paginated() + .Items(allShardStrings) + .PageSize(25) + .CurrentPage(page) + .Page((items, _) => + { + var str = string.Join("\n", items); + + if (string.IsNullOrWhiteSpace(str)) + str = GetText(strs.no_shards_on_page); + + return _sender.CreateEmbed().WithOkColor().WithDescription($"{status}\n\n{str}"); + }) + .SendAsync(); + } + + private static string ConnectionStateToEmoji(ShardStatus status) + { + var timeDiff = DateTime.UtcNow - status.LastUpdate; + return status.ConnectionState switch + { + ConnectionState.Disconnected => "🔻", + _ when timeDiff > TimeSpan.FromSeconds(30) => " ❗ ", + ConnectionState.Connected => "✅", + _ => " ⏳" + }; + } + + [Cmd] + [OwnerOnly] + public async Task RestartShard(int shardId) + { + var success = _coord.RestartShard(shardId); + if (success) + await Response().Confirm(strs.shard_reconnecting(Format.Bold("#" + shardId))).SendAsync(); + else + await Response().Error(strs.no_shard_id).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public Task Leave([Leftover] string guildStr) + => _service.LeaveGuild(guildStr); + + [Cmd] + [OwnerOnly] + public async Task DeleteEmptyServers() + { + await ctx.Channel.TriggerTypingAsync(); + + var toLeave = _client.Guilds + .Where(s => s.MemberCount == 1 && s.Users.Count == 1) + .ToList(); + + foreach (var server in toLeave) + { + try + { + await server.DeleteAsync(); + Log.Information("Deleted server {ServerName} [{ServerId}]", + server.Name, + server.Id); + } + catch (Exception ex) + { + Log.Warning(ex, + "Error leaving server {ServerName} [{ServerId}]", + server.Name, + server.Id); + } + } + + await Response().Confirm(strs.deleted_x_servers(toLeave.Count)).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task Die(bool graceful = false) + { + try + { + await _client.SetStatusAsync(UserStatus.Invisible); + _ = _client.StopAsync(); + await Response().Confirm(strs.shutting_down).SendAsync(); + } + catch + { + // ignored + } + + await Task.Delay(2000); + _coord.Die(graceful); + } + + [Cmd] + [OwnerOnly] + public async Task Restart() + { + var success = _coord.RestartBot(); + if (!success) + { + await Response().Error(strs.restart_fail).SendAsync(); + return; + } + + try { await Response().Confirm(strs.restarting).SendAsync(); } + catch { } + } + + [Cmd] + [OwnerOnly] + public async Task SetName([Leftover] string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + return; + + try + { + await _client.CurrentUser.ModifyAsync(u => u.Username = newName); + } + catch (RateLimitedException) + { + Log.Warning("You've been ratelimited. Wait 2 hours to change your name"); + } + + await Response().Confirm(strs.bot_name(Format.Bold(newName))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetStatus([Leftover] SettableUserStatus status) + { + await _client.SetStatusAsync(SettableUserStatusToUserStatus(status)); + + await Response().Confirm(strs.bot_status(Format.Bold(status.ToString()))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetAvatar([Leftover] string img = null) + { + var success = await _service.SetAvatar(img); + + if (success) + await Response().Confirm(strs.set_avatar).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetBanner([Leftover] string img = null) + { + var success = await _service.SetBanner(img); + + if (success) + await Response().Confirm(strs.set_banner).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetGame(ActivityType type, [Leftover] string game = null) + { + // var rep = new ReplacementBuilder().WithDefault(Context).Build(); + + var repCtx = new ReplacementContext(ctx); + await _service.SetGameAsync(game is null ? game : await repSvc.ReplaceAsync(game, repCtx), type); + + await Response().Confirm(strs.set_game).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetStream(string url, [Leftover] string name = null) + { + name ??= ""; + + await _service.SetStreamAsync(name, url); + + await Response().Confirm(strs.set_stream).SendAsync(); + } + + public enum SendWhere + { + User = 0, + U = 0, + Usr = 0, + + Channel = 1, + Ch = 1, + Chan = 1, + } + + [Cmd] + [OwnerOnly] + public async Task Send(SendWhere to, ulong id, [Leftover] SmartText text) + { + var ch = to switch + { + SendWhere.User => await ((await _client.Rest.GetUserAsync(id))?.CreateDMChannelAsync() + ?? Task.FromResult(null)), + SendWhere.Channel => await _client.Rest.GetChannelAsync(id) as IMessageChannel, + _ => null + }; + + if (ch is null) + { + await Response().Error(strs.invalid_format).SendAsync(); + return; + } + + + var repCtx = new ReplacementContext(ctx); + text = await repSvc.ReplaceAsync(text, repCtx); + await Response().Channel(ch).Text(text).SendAsync(); + + await ctx.OkAsync();; + } + + [Cmd] + [OwnerOnly] + public async Task StringsReload() + { + _strings.Reload(); + await _marmaladeLoader.ReloadStrings(); + await Response().Confirm(strs.bot_strings_reloaded).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task CoordReload() + { + await _coord.Reload(); + await ctx.OkAsync(); + } + + private static UserStatus SettableUserStatusToUserStatus(SettableUserStatus sus) + { + switch (sus) + { + case SettableUserStatus.Online: + return UserStatus.Online; + case SettableUserStatus.Invisible: + return UserStatus.Invisible; + case SettableUserStatus.Idle: + return UserStatus.AFK; + case SettableUserStatus.Dnd: + return UserStatus.DoNotDisturb; + } + + return UserStatus.Online; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Self/SelfService.cs b/src/EllieBot/Modules/Administration/Self/SelfService.cs new file mode 100644 index 0000000..82eb68c --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/SelfService.cs @@ -0,0 +1,485 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Collections.Immutable; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class SelfService : IExecNoCommand, IReadyExecutor, IEService +{ + private readonly CommandHandler _cmdHandler; + private readonly DbService _db; + private readonly IBotStrings _strings; + private readonly DiscordSocketClient _client; + + private readonly IBotCredentials _creds; + + private ImmutableDictionary ownerChannels = + new Dictionary().ToImmutableDictionary(); + + private ConcurrentDictionary> autoCommands = new(); + + private readonly IHttpClientFactory _httpFactory; + private readonly BotConfigService _bss; + private readonly IPubSub _pubSub; + private readonly IMessageSenderService _sender; + + //keys + private readonly TypedKey _activitySetKey; + private readonly TypedKey _guildLeaveKey; + + public SelfService( + DiscordSocketClient client, + CommandHandler cmdHandler, + DbService db, + IBotStrings strings, + IBotCredentials creds, + IHttpClientFactory factory, + BotConfigService bss, + IPubSub pubSub, + IMessageSenderService sender) + { + _cmdHandler = cmdHandler; + _db = db; + _strings = strings; + _client = client; + _creds = creds; + _httpFactory = factory; + _bss = bss; + _pubSub = pubSub; + _sender = sender; + _activitySetKey = new("activity.set"); + _guildLeaveKey = new("guild.leave"); + + HandleStatusChanges(); + + _pubSub.Sub(_guildLeaveKey, + async input => + { + var guildStr = input.ToString().Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(guildStr)) + return; + + var server = _client.Guilds.FirstOrDefault(g => g.Id.ToString() == guildStr + || g.Name.Trim().ToUpperInvariant() == guildStr); + if (server is null) + return; + + if (server.OwnerId != _client.CurrentUser.Id) + { + await server.LeaveAsync(); + Log.Information("Left server {Name} [{Id}]", server.Name, server.Id); + } + else + { + await server.DeleteAsync(); + Log.Information("Deleted server {Name} [{Id}]", server.Name, server.Id); + } + }); + } + + public async Task OnReadyAsync() + { + await using var uow = _db.GetDbContext(); + + autoCommands = uow.Set().AsNoTracking() + .Where(x => x.Interval >= 5) + .AsEnumerable() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, + y => y.ToDictionary(x => x.Id, TimerFromAutoCommand).ToConcurrent()) + .ToConcurrent(); + + var startupCommands = uow.Set().AsNoTracking().Where(x => x.Interval == 0); + foreach (var cmd in startupCommands) + { + try + { + await ExecuteCommand(cmd); + } + catch + { + } + } + + if (_client.ShardId == 0) + await LoadOwnerChannels(); + } + + private Timer TimerFromAutoCommand(AutoCommand x) + => new(async obj => await ExecuteCommand((AutoCommand)obj), x, x.Interval * 1000, x.Interval * 1000); + + private async Task ExecuteCommand(AutoCommand cmd) + { + try + { + if (cmd.GuildId is null) + return; + + var guildShard = (int)((cmd.GuildId.Value >> 22) % (ulong)_creds.TotalShards); + if (guildShard != _client.ShardId) + return; + var prefix = _cmdHandler.GetPrefix(cmd.GuildId); + //if someone already has .die as their startup command, ignore it + if (cmd.CommandText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + await _cmdHandler.ExecuteExternal(cmd.GuildId, cmd.ChannelId, cmd.CommandText); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in SelfService ExecuteCommand"); + } + } + + public void AddNewAutoCommand(AutoCommand cmd) + { + using (var uow = _db.GetDbContext()) + { + uow.Set().Add(cmd); + uow.SaveChanges(); + } + + if (cmd.Interval >= 5) + { + var autos = autoCommands.GetOrAdd(cmd.GuildId, new ConcurrentDictionary()); + autos.AddOrUpdate(cmd.Id, + _ => TimerFromAutoCommand(cmd), + (_, old) => + { + old.Change(Timeout.Infinite, Timeout.Infinite); + return TimerFromAutoCommand(cmd); + }); + } + } + + public IEnumerable GetStartupCommands() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().Where(x => x.Interval == 0).OrderBy(x => x.Id).ToList(); + } + + public IEnumerable GetAutoCommands() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().Where(x => x.Interval >= 5).OrderBy(x => x.Id).ToList(); + } + + private async Task LoadOwnerChannels() + { + var channels = await _creds.OwnerIds.Select(id => + { + var user = _client.GetUser(id); + if (user is null) + return Task.FromResult(null); + + return user.CreateDMChannelAsync(); + }) + .WhenAll(); + + ownerChannels = channels.Where(x => x is not null) + .ToDictionary(x => x.Recipient.Id, x => x) + .ToImmutableDictionary(); + + if (!ownerChannels.Any()) + { + Log.Warning( + "No owner channels created! Make sure you've specified the correct OwnerId in the creds.yml file and invited the bot to a Discord server"); + } + else + { + Log.Information("Created {OwnerChannelCount} out of {TotalOwnerChannelCount} owner message channels", + ownerChannels.Count, + _creds.OwnerIds.Count); + } + } + + public Task LeaveGuild(string guildStr) + => _pubSub.Pub(_guildLeaveKey, guildStr); + + // forwards dms + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + var bs = _bss.Data; + if (msg.Channel is IDMChannel && bs.ForwardMessages && (ownerChannels.Any() || bs.ForwardToChannel is not null)) + { + var title = _strings.GetText(strs.dm_from) + $" [{msg.Author}]({msg.Author.Id})"; + + var attachamentsTxt = _strings.GetText(strs.attachments); + + var toSend = msg.Content; + + if (msg.Attachments.Count > 0) + { + toSend += $"\n\n{Format.Code(attachamentsTxt)}:\n" + + string.Join("\n", msg.Attachments.Select(a => a.ProxyUrl)); + } + + if (bs.ForwardToAllOwners) + { + var allOwnerChannels = ownerChannels.Values; + + foreach (var ownerCh in allOwnerChannels.Where(ch => ch.Recipient.Id != msg.Author.Id)) + { + try + { + await _sender.Response(ownerCh).Confirm(title, toSend).SendAsync(); + } + catch + { + Log.Warning("Can't contact owner with id {OwnerId}", ownerCh.Recipient.Id); + } + } + } + else if (bs.ForwardToChannel is ulong cid) + { + try + { + if (_client.GetChannel(cid) is ITextChannel ch) + await _sender.Response(ch).Confirm(title, toSend).SendAsync(); + } + catch + { + Log.Warning("Error forwarding message to the channel"); + } + } + else + { + var firstOwnerChannel = ownerChannels.Values.First(); + if (firstOwnerChannel.Recipient.Id != msg.Author.Id) + { + try + { + await _sender.Response(firstOwnerChannel).Confirm(title, toSend).SendAsync(); + } + catch + { + // ignored + } + } + } + } + } + + public bool RemoveStartupCommand(int index, out AutoCommand cmd) + { + using var uow = _db.GetDbContext(); + cmd = uow.Set().AsNoTracking().Where(x => x.Interval == 0).Skip(index).FirstOrDefault(); + + if (cmd is not null) + { + uow.Remove(cmd); + uow.SaveChanges(); + return true; + } + + return false; + } + + public bool RemoveAutoCommand(int index, out AutoCommand cmd) + { + using var uow = _db.GetDbContext(); + cmd = uow.Set().AsNoTracking().Where(x => x.Interval >= 5).Skip(index).FirstOrDefault(); + + if (cmd is not null) + { + uow.Remove(cmd); + if (autoCommands.TryGetValue(cmd.GuildId, out var autos)) + { + if (autos.TryRemove(cmd.Id, out var timer)) + timer.Change(Timeout.Infinite, Timeout.Infinite); + } + + uow.SaveChanges(); + return true; + } + + return false; + } + + public async Task SetAvatar(string img) + { + if (string.IsNullOrWhiteSpace(img)) + return false; + + if (!Uri.IsWellFormedUriString(img, UriKind.Absolute)) + return false; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + if (!sr.IsImage()) + return false; + + // i can't just do ReadAsStreamAsync because dicord.net's image poops itself + var imgData = await sr.Content.ReadAsByteArrayAsync(); + await using var imgStream = imgData.ToStream(); + await _client.CurrentUser.ModifyAsync(u => u.Avatar = new Image(imgStream)); + + return true; + } + + public async Task SetBanner(string img) + { + if (string.IsNullOrWhiteSpace(img)) + { + return false; + } + + if (!Uri.IsWellFormedUriString(img, UriKind.Absolute)) + { + return false; + } + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + + if (!sr.IsImage()) + { + return false; + } + + if (sr.GetContentLength() > 8.Megabytes()) + { + return false; + } + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await _client.CurrentUser.ModifyAsync(x => x.Banner = new Image(imageStream)); + return true; + } + + + public void ClearStartupCommands() + { + using var uow = _db.GetDbContext(); + var toRemove = uow.Set().AsNoTracking().Where(x => x.Interval == 0); + + uow.Set().RemoveRange(toRemove); + uow.SaveChanges(); + } + + public bool ForwardMessages() + { + var isForwarding = false; + _bss.ModifyConfig(config => { isForwarding = config.ForwardMessages = !config.ForwardMessages; }); + + return isForwarding; + } + + public bool ForwardToAll() + { + var isToAll = false; + _bss.ModifyConfig(config => { isToAll = config.ForwardToAllOwners = !config.ForwardToAllOwners; }); + return isToAll; + } + + public bool ForwardToChannel(ulong? channelId) + { + using var uow = _db.GetDbContext(); + + _bss.ModifyConfig(config => + { + config.ForwardToChannel = channelId == config.ForwardToChannel + ? null + : channelId; + }); + + return channelId is not null; + } + + private void HandleStatusChanges() + => _pubSub.Sub(_activitySetKey, + async data => + { + try + { + await _client.SetGameAsync(data.Name, data.Link, data.Type); + } + catch (Exception ex) + { + Log.Warning(ex, "Error setting activity"); + } + }); + + public Task SetGameAsync(string game, ActivityType type) + => _pubSub.Pub(_activitySetKey, + new() + { + Name = game, + Link = null, + Type = type + }); + + public Task SetStreamAsync(string name, string link) + => _pubSub.Pub(_activitySetKey, + new() + { + Name = name, + Link = link, + Type = ActivityType.Streaming + }); + + private sealed class ActivityPubData + { + public string Name { get; init; } + public string Link { get; init; } + public ActivityType Type { get; init; } + } + + + /// + /// Adds the specified to the database. If a database user with placeholder name + /// and discriminator is present in , their name and discriminator get updated accordingly. + /// + /// This database context. + /// The users to add or update in the database. + /// A tuple with the amount of new users added and old users updated. + public async Task<(long UsersAdded, long UsersUpdated)> RefreshUsersAsync(List users) + { + await using var ctx = _db.GetDbContext(); + var presentDbUsers = await ctx.GetTable() + .Select(x => new { x.UserId, x.Username, x.Discriminator }) + .Where(x => users.Select(y => y.Id).Contains(x.UserId)) + .ToArrayAsyncEF(); + + var usersToAdd = users + .Where(x => !presentDbUsers.Select(x => x.UserId).Contains(x.Id)) + .Select(x => new DiscordUser() + { + UserId = x.Id, + AvatarId = x.AvatarId, + Username = x.Username, + Discriminator = x.Discriminator + }); + + var added = (await ctx.BulkCopyAsync(usersToAdd)).RowsCopied; + var toUpdateUserIds = presentDbUsers + .Where(x => x.Username == "Unknown" && x.Discriminator == "????") + .Select(x => x.UserId) + .ToArray(); + + foreach (var user in users.Where(x => toUpdateUserIds.Contains(x.Id))) + { + await ctx.GetTable() + .Where(x => x.UserId == user.Id) + .UpdateAsync(x => new DiscordUser() + { + Username = user.Username, + Discriminator = user.Discriminator, + + // .award tends to set AvatarId and DateAdded to NULL, so account for that. + AvatarId = user.AvatarId, + DateAdded = x.DateAdded ?? DateTime.UtcNow + }); + } + + return (added, toUpdateUserIds.Length); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs new file mode 100644 index 0000000..70581f7 --- /dev/null +++ b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs @@ -0,0 +1,239 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; +using System.Text; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class SelfAssignedRolesCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.ManageMessages)] + public async Task AdSarm() + { + var newVal = _service.ToggleAdSarm(ctx.Guild.Id); + + if (newVal) + await Response().Confirm(strs.adsarm_enable(prefix)).SendAsync(); + else + await Response().Confirm(strs.adsarm_disable(prefix)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(1)] + public Task Asar([Leftover] IRole role) + => Asar(0, role); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task Asar(int group, [Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + var succ = _service.AddNew(ctx.Guild.Id, role, group); + + if (succ) + { + await Response() + .Confirm(strs.role_added(Format.Bold(role.Name), Format.Bold(group.ToString()))) + .SendAsync(); + } + else + await Response().Error(strs.role_in_list(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task Sargn(int group, [Leftover] string name = null) + { + var set = await _service.SetNameAsync(ctx.Guild.Id, group, name); + + if (set) + { + await Response() + .Confirm(strs.group_name_added(Format.Bold(group.ToString()), Format.Bold(name))) + .SendAsync(); + } + else + await Response().Confirm(strs.group_name_removed(Format.Bold(group.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task Rsar([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + var success = _service.RemoveSar(role.Guild.Id, role.Id); + if (!success) + await Response().Error(strs.self_assign_not).SendAsync(); + else + await Response().Confirm(strs.self_assign_rem(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Lsar(int page = 1) + { + if (--page < 0) + return; + + var (exclusive, roles, groups) = _service.GetRoles(ctx.Guild); + + await Response() + .Paginated() + .Items(roles.OrderBy(x => x.Model.Group).ToList()) + .PageSize(20) + .CurrentPage(page) + .Page((items, _) => + { + var rolesStr = new StringBuilder(); + var roleGroups = items + .GroupBy(x => x.Model.Group) + .OrderBy(x => x.Key); + + foreach (var kvp in roleGroups) + { + string groupNameText; + if (!groups.TryGetValue(kvp.Key, out var name)) + groupNameText = Format.Bold(GetText(strs.self_assign_group(kvp.Key))); + else + groupNameText = Format.Bold($"{kvp.Key} - {name.TrimTo(25, true)}"); + + rolesStr.AppendLine("\t\t\t\t ⟪" + groupNameText + "⟫"); + foreach (var (model, role) in kvp.AsEnumerable()) + { + if (role is null) + { + } + else + { + // first character is invisible space + if (model.LevelRequirement == 0) + rolesStr.AppendLine("‌‌ " + role.Name); + else + rolesStr.AppendLine("‌‌ " + role.Name + $" (lvl {model.LevelRequirement}+)"); + } + } + + rolesStr.AppendLine(); + } + + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(Format.Bold(GetText(strs.self_assign_list(roles.Count())))) + .WithDescription(rolesStr.ToString()) + .WithFooter(exclusive + ? GetText(strs.self_assign_are_exclusive) + : GetText(strs.self_assign_are_not_exclusive)); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task Togglexclsar() + { + var areExclusive = _service.ToggleEsar(ctx.Guild.Id); + if (areExclusive) + await Response().Confirm(strs.self_assign_excl).SendAsync(); + else + await Response().Confirm(strs.self_assign_no_excl).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RoleLevelReq(int level, [Leftover] IRole role) + { + if (level < 0) + return; + + var succ = _service.SetLevelReq(ctx.Guild.Id, role, level); + + if (!succ) + { + await Response().Error(strs.self_assign_not).SendAsync(); + return; + } + + await Response() + .Confirm(strs.self_assign_level_req(Format.Bold(role.Name), + Format.Bold(level.ToString()))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Iam([Leftover] IRole role) + { + var guildUser = (IGuildUser)ctx.User; + + var (result, autoDelete, extra) = await _service.Assign(guildUser, role); + + IUserMessage msg; + if (result == SelfAssignedRolesService.AssignResult.ErrNotAssignable) + msg = await Response().Error(strs.self_assign_not).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrLvlReq) + msg = await Response().Error(strs.self_assign_not_level(Format.Bold(extra.ToString()))).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrAlreadyHave) + msg = await Response().Error(strs.self_assign_already(Format.Bold(role.Name))).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrNotPerms) + msg = await Response().Error(strs.self_assign_perms).SendAsync(); + else + msg = await Response().Confirm(strs.self_assign_success(Format.Bold(role.Name))).SendAsync(); + + if (autoDelete) + { + msg.DeleteAfter(3); + ctx.Message.DeleteAfter(3); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Iamnot([Leftover] IRole role) + { + var guildUser = (IGuildUser)ctx.User; + + var (result, autoDelete) = await _service.Remove(guildUser, role); + + IUserMessage msg; + if (result == SelfAssignedRolesService.RemoveResult.ErrNotAssignable) + msg = await Response().Error(strs.self_assign_not).SendAsync(); + else if (result == SelfAssignedRolesService.RemoveResult.ErrNotHave) + msg = await Response().Error(strs.self_assign_not_have(Format.Bold(role.Name))).SendAsync(); + else if (result == SelfAssignedRolesService.RemoveResult.ErrNotPerms) + msg = await Response().Error(strs.self_assign_perms).SendAsync(); + else + msg = await Response().Confirm(strs.self_assign_remove(Format.Bold(role.Name))).SendAsync(); + + if (autoDelete) + { + msg.DeleteAfter(3); + ctx.Message.DeleteAfter(3); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs new file mode 100644 index 0000000..1305835 --- /dev/null +++ b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs @@ -0,0 +1,234 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public class SelfAssignedRolesService : IEService +{ + public enum AssignResult + { + Assigned, // successfully removed + ErrNotAssignable, // not assignable (error) + ErrAlreadyHave, // you already have that role (error) + ErrNotPerms, // bot doesn't have perms (error) + ErrLvlReq // you are not required level (error) + } + + public enum RemoveResult + { + Removed, // successfully removed + ErrNotAssignable, // not assignable (error) + ErrNotHave, // you don't have a role you want to remove (error) + ErrNotPerms // bot doesn't have perms (error) + } + + private readonly DbService _db; + + public SelfAssignedRolesService(DbService db) + => _db = db; + + public bool AddNew(ulong guildId, IRole role, int group) + { + using var uow = _db.GetDbContext(); + var roles = uow.Set().GetFromGuild(guildId); + if (roles.Any(s => s.RoleId == role.Id && s.GuildId == role.Guild.Id)) + return false; + + uow.Set().Add(new() + { + Group = group, + RoleId = role.Id, + GuildId = role.Guild.Id + }); + uow.SaveChanges(); + return true; + } + + public bool ToggleAdSarm(ulong guildId) + { + bool newval; + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + newval = config.AutoDeleteSelfAssignedRoleMessages = !config.AutoDeleteSelfAssignedRoleMessages; + uow.SaveChanges(); + return newval; + } + + public async Task<(AssignResult Result, bool AutoDelete, object extra)> Assign(IGuildUser guildUser, IRole role) + { + LevelStats userLevelData; + await using (var uow = _db.GetDbContext()) + { + var stats = uow.GetOrCreateUserXpStats(guildUser.Guild.Id, guildUser.Id); + userLevelData = new(stats.Xp + stats.AwardedXp); + } + + var (autoDelete, exclusive, roles) = GetAdAndRoles(guildUser.Guild.Id); + + var theRoleYouWant = roles.FirstOrDefault(r => r.RoleId == role.Id); + if (theRoleYouWant is null) + return (AssignResult.ErrNotAssignable, autoDelete, null); + if (theRoleYouWant.LevelRequirement > userLevelData.Level) + return (AssignResult.ErrLvlReq, autoDelete, theRoleYouWant.LevelRequirement); + if (guildUser.RoleIds.Contains(role.Id)) + return (AssignResult.ErrAlreadyHave, autoDelete, null); + + var roleIds = roles.Where(x => x.Group == theRoleYouWant.Group).Select(x => x.RoleId).ToArray(); + if (exclusive) + { + var sameRoles = guildUser.RoleIds.Where(r => roleIds.Contains(r)); + + foreach (var roleId in sameRoles) + { + var sameRole = guildUser.Guild.GetRole(roleId); + if (sameRole is not null) + { + try + { + await guildUser.RemoveRoleAsync(sameRole); + await Task.Delay(300); + } + catch + { + // ignored + } + } + } + } + + try + { + await guildUser.AddRoleAsync(role); + } + catch (Exception ex) + { + return (AssignResult.ErrNotPerms, autoDelete, ex); + } + + return (AssignResult.Assigned, autoDelete, null); + } + + public async Task SetNameAsync(ulong guildId, int group, string name) + { + var set = false; + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, y => y.Include(x => x.SelfAssignableRoleGroupNames)); + var toUpdate = gc.SelfAssignableRoleGroupNames.FirstOrDefault(x => x.Number == group); + + if (string.IsNullOrWhiteSpace(name)) + { + if (toUpdate is not null) + gc.SelfAssignableRoleGroupNames.Remove(toUpdate); + } + else if (toUpdate is null) + { + gc.SelfAssignableRoleGroupNames.Add(new() + { + Name = name, + Number = group + }); + set = true; + } + else + { + toUpdate.Name = name; + set = true; + } + + await uow.SaveChangesAsync(); + + return set; + } + + public async Task<(RemoveResult Result, bool AutoDelete)> Remove(IGuildUser guildUser, IRole role) + { + var (autoDelete, _, roles) = GetAdAndRoles(guildUser.Guild.Id); + + if (roles.FirstOrDefault(r => r.RoleId == role.Id) is null) + return (RemoveResult.ErrNotAssignable, autoDelete); + if (!guildUser.RoleIds.Contains(role.Id)) + return (RemoveResult.ErrNotHave, autoDelete); + try + { + await guildUser.RemoveRoleAsync(role); + } + catch (Exception) + { + return (RemoveResult.ErrNotPerms, autoDelete); + } + + return (RemoveResult.Removed, autoDelete); + } + + public bool RemoveSar(ulong guildId, ulong roleId) + { + bool success; + using var uow = _db.GetDbContext(); + success = uow.Set().DeleteByGuildAndRoleId(guildId, roleId); + uow.SaveChanges(); + return success; + } + + public (bool AutoDelete, bool Exclusive, IReadOnlyCollection) GetAdAndRoles(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var autoDelete = gc.AutoDeleteSelfAssignedRoleMessages; + var exclusive = gc.ExclusiveSelfAssignedRoles; + var roles = uow.Set().GetFromGuild(guildId); + + return (autoDelete, exclusive, roles); + } + + public bool SetLevelReq(ulong guildId, IRole role, int level) + { + using var uow = _db.GetDbContext(); + var roles = uow.Set().GetFromGuild(guildId); + var sar = roles.FirstOrDefault(x => x.RoleId == role.Id); + if (sar is not null) + { + sar.LevelRequirement = level; + uow.SaveChanges(); + } + else + return false; + + return true; + } + + public bool ToggleEsar(ulong guildId) + { + bool areExclusive; + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + + areExclusive = config.ExclusiveSelfAssignedRoles = !config.ExclusiveSelfAssignedRoles; + uow.SaveChanges(); + return areExclusive; + } + + public (bool Exclusive, IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> Roles, IDictionary + GroupNames + ) GetRoles(IGuild guild) + { + var exclusive = false; + + IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> roles; + IDictionary groupNames; + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.SelfAssignableRoleGroupNames)); + exclusive = gc.ExclusiveSelfAssignedRoles; + groupNames = gc.SelfAssignableRoleGroupNames.ToDictionary(x => x.Number, x => x.Name); + var roleModels = uow.Set().GetFromGuild(guild.Id); + roles = roleModels.Select(x => (Model: x, Role: guild.GetRole(x.RoleId))) + .ToList(); + uow.Set().RemoveRange(roles.Where(x => x.Role is null).Select(x => x.Model).ToArray()); + uow.SaveChanges(); + } + + return (exclusive, roles.Where(x => x.Role is not null).ToList(), groupNames); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs b/src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs new file mode 100644 index 0000000..3cdd4d3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs @@ -0,0 +1,25 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public sealed class DummyLogCommandService : ILogCommandService +#if GLOBAL_ELLIE +, INService +#endif +{ + public void AddDeleteIgnore(ulong xId) + { + } + + public Task LogServer(ulong guildId, ulong channelId, bool actionValue) + => Task.CompletedTask; + + public bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType) + => false; + + public LogSetting? GetGuildLogSettings(ulong guildId) + => default; + + public bool Log(ulong guildId, ulong? channelId, LogType type) + => false; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs new file mode 100644 index 0000000..6ea5345 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs @@ -0,0 +1,1297 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public sealed class LogCommandService : ILogCommandService, IReadyExecutor +#if !GLOBAL_ELLIE + , IEService // don't load this service on global ellie +#endif +{ + public ConcurrentDictionary GuildLogSettings { get; } + + private ConcurrentDictionary> PresenceUpdates { get; } = new(); + private readonly DiscordSocketClient _client; + + private readonly IBotStrings _strings; + private readonly DbService _db; + private readonly MuteService _mute; + private readonly ProtectionService _prot; + private readonly GuildTimezoneService _tz; + private readonly IMemoryCache _memoryCache; + + private readonly ConcurrentHashSet _ignoreMessageIds = []; + private readonly UserPunishService _punishService; + private readonly IMessageSenderService _sender; + + public LogCommandService( + DiscordSocketClient client, + IBotStrings strings, + DbService db, + MuteService mute, + ProtectionService prot, + GuildTimezoneService tz, + IMemoryCache memoryCache, + UserPunishService punishService, + IMessageSenderService sender) + { + _client = client; + _memoryCache = memoryCache; + _sender = sender; + _strings = strings; + _db = db; + _mute = mute; + _prot = prot; + _tz = tz; + _punishService = punishService; + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set().AsQueryable() + .AsNoTracking() + .Where(x => guildIds.Contains(x.GuildId)) + .Include(ls => ls.LogIgnores) + .ToList(); + + GuildLogSettings = configs.ToDictionary(ls => ls.GuildId).ToConcurrent(); + } + + //_client.MessageReceived += _client_MessageReceived; + _client.MessageUpdated += _client_MessageUpdated; + _client.MessageDeleted += _client_MessageDeleted; + _client.UserBanned += _client_UserBanned; + _client.UserUnbanned += _client_UserUnbanned; + _client.UserJoined += _client_UserJoined; + _client.UserLeft += _client_UserLeft; + // _client.PresenceUpdated += _client_UserPresenceUpdated; + _client.UserVoiceStateUpdated += _client_UserVoiceStateUpdated; + _client.GuildMemberUpdated += _client_GuildUserUpdated; + _client.PresenceUpdated += _client_PresenceUpdated; + _client.UserUpdated += _client_UserUpdated; + _client.ChannelCreated += _client_ChannelCreated; + _client.ChannelDestroyed += _client_ChannelDestroyed; + _client.ChannelUpdated += _client_ChannelUpdated; + _client.RoleDeleted += _client_RoleDeleted; + + _client.ThreadCreated += _client_ThreadCreated; + _client.ThreadDeleted += _client_ThreadDeleted; + + _mute.UserMuted += MuteCommands_UserMuted; + _mute.UserUnmuted += MuteCommands_UserUnmuted; + + _prot.OnAntiProtectionTriggered += TriggeredAntiProtection; + + _punishService.OnUserWarned += PunishServiceOnOnUserWarned; + } + + private async Task _client_PresenceUpdated(SocketUser user, SocketPresence? before, SocketPresence? after) + { + if (user is not SocketGuildUser gu) + return; + + if (!GuildLogSettings.TryGetValue(gu.Guild.Id, out var logSetting) + || before is null + || after is null + || logSetting.LogIgnores.Any(ilc => ilc.LogItemId == gu.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + + if (!user.IsBot + && logSetting.LogUserPresenceId is not null + && (logChannel = + await TryGetLogChannel(gu.Guild, logSetting, LogType.UserPresence)) is not null) + { + if (before.Status != after.Status) + { + var str = "🎭" + + Format.Code(PrettyCurrentTime(gu.Guild)) + + GetText(logChannel.Guild, + strs.user_status_change("👤" + Format.Bold(gu.Username), + Format.Bold(after.Status.ToString()))); + PresenceUpdates.AddOrUpdate(logChannel, + [str], + (_, list) => + { + list.Add(str); + return list; + }); + } + else if (before.Activities.FirstOrDefault()?.Name != after.Activities.FirstOrDefault()?.Name) + { + var str = + $"👾`{PrettyCurrentTime(gu.Guild)}`👤__**{gu.Username}**__ is now playing **{after.Activities.FirstOrDefault()?.Name ?? "-"}**."; + PresenceUpdates.AddOrUpdate(logChannel, + [str], + (_, list) => + { + list.Add(str); + return list; + }); + } + } + } + + private Task _client_ThreadDeleted(Cacheable sch) + { + _ = Task.Run(async () => + { + try + { + if (!sch.HasValue) + return; + + var ch = sch.Value; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ThreadDeletedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ThreadDeleted)) is null) + return; + + var title = GetText(logChannel.Guild, strs.thread_deleted); + + await _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🗑 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ThreadCreated(SocketThreadChannel ch) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ThreadCreatedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ThreadCreated)) is null) + return; + + var title = GetText(logChannel.Guild, strs.thread_created); + + await _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + + return Task.CompletedTask; + } + + public async Task OnReadyAsync() + => await Task.WhenAll(PresenceUpdateTask(), IgnoreMessageIdsClearTask()); + + private async Task IgnoreMessageIdsClearTask() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + _ignoreMessageIds.Clear(); + } + + private async Task PresenceUpdateTask() + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(15)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var keys = PresenceUpdates.Keys.ToList(); + + await keys.Select(channel => + { + if (!((SocketGuild)channel.Guild).CurrentUser.GetPermissions(channel).SendMessages) + return Task.CompletedTask; + + if (PresenceUpdates.TryRemove(channel, out var msgs)) + { + var title = GetText(channel.Guild, strs.presence_updates); + var desc = string.Join(Environment.NewLine, msgs); + return _sender.Response(channel).Confirm(title, desc.TrimTo(2048)!).SendAsync(); + } + + return Task.CompletedTask; + }) + .WhenAll(); + } + catch + { + } + } + } + + public LogSetting? GetGuildLogSettings(ulong guildId) + { + GuildLogSettings.TryGetValue(guildId, out var logSetting); + return logSetting; + } + + public void AddDeleteIgnore(ulong messageId) + => _ignoreMessageIds.Add(messageId); + + public bool LogIgnore(ulong gid, ulong itemId, IgnoredItemType itemType) + { + using var uow = _db.GetDbContext(); + var logSetting = uow.LogSettingsFor(gid); + var removed = logSetting.LogIgnores.RemoveAll(x => x.ItemType == itemType && itemId == x.LogItemId); + + if (removed == 0) + { + var toAdd = new IgnoredLogItem + { + LogItemId = itemId, + ItemType = itemType + }; + logSetting.LogIgnores.Add(toAdd); + } + + uow.SaveChanges(); + GuildLogSettings.AddOrUpdate(gid, logSetting, (_, _) => logSetting); + return removed > 0; + } + + private string GetText(IGuild guild, LocStr str) + => _strings.GetText(str, guild.Id); + + private string PrettyCurrentTime(IGuild? g) + { + var time = DateTime.UtcNow; + if (g is not null) + time = TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(g.Id)); + return $"【{time:HH:mm:ss}】"; + } + + private string CurrentTime(IGuild? g) + { + var time = DateTime.UtcNow; + if (g is not null) + time = TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(g.Id)); + + return $"{time:HH:mm:ss}"; + } + + public async Task LogServer(ulong guildId, ulong channelId, bool value) + { + await using var uow = _db.GetDbContext(); + var logSetting = uow.LogSettingsFor(guildId); + + logSetting.LogOtherId = logSetting.MessageUpdatedId = logSetting.MessageDeletedId = logSetting.UserJoinedId = + logSetting.UserLeftId = logSetting.UserBannedId = logSetting.UserUnbannedId = logSetting.UserUpdatedId = + logSetting.ChannelCreatedId = logSetting.ChannelDestroyedId = logSetting.ChannelUpdatedId = + logSetting.LogUserPresenceId = logSetting.LogVoicePresenceId = logSetting.UserMutedId = + logSetting.ThreadCreatedId = logSetting.ThreadDeletedId + = logSetting.LogWarnsId = value ? channelId : null; + await uow.SaveChangesAsync(); + GuildLogSettings.AddOrUpdate(guildId, _ => logSetting, (_, _) => logSetting); + } + + + private async Task PunishServiceOnOnUserWarned(Warning arg) + { + if (!GuildLogSettings.TryGetValue(arg.GuildId, out var logSetting) || logSetting.LogWarnsId is null) + return; + + var g = _client.GetGuild(arg.GuildId); + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(g, logSetting, LogType.UserWarned)) is null) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"⚠️ User Warned") + .WithDescription($"<@{arg.UserId}> | {arg.UserId}") + .AddField("Mod", arg.Moderator) + .AddField("Reason", string.IsNullOrWhiteSpace(arg.Reason) ? "-" : arg.Reason, true) + .WithFooter(CurrentTime(g)); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + + private Task _client_UserUpdated(SocketUser before, SocketUser uAfter) + { + _ = Task.Run(async () => + { + try + { + if (uAfter is not SocketGuildUser after) + return; + + var g = after.Guild; + + if (!GuildLogSettings.TryGetValue(g.Id, out var logSetting) || logSetting.UserUpdatedId is null || logSetting.LogIgnores.Any(ilc => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(g, logSetting, LogType.UserUpdated)) is null) + return; + + var embed = _sender.CreateEmbed(); + + if (before.Username != after.Username) + { + embed.WithTitle("👥 " + GetText(g, strs.username_changed)) + .WithDescription($"{before.Username} | {before.Id}") + .AddField("Old Name", $"{before.Username}", true) + .AddField("New Name", $"{after.Username}", true) + .WithFooter(CurrentTime(g)) + .WithOkColor(); + } + else if (before.AvatarId != after.AvatarId) + { + embed.WithTitle("👥" + GetText(g, strs.avatar_changed)) + .WithDescription($"{before.Username}#{before.Discriminator} | {before.Id}") + .WithFooter(CurrentTime(g)) + .WithOkColor(); + + var bav = before.RealAvatarUrl(); + if (bav.IsAbsoluteUri) + embed.WithThumbnailUrl(bav.ToString()); + + var aav = after.RealAvatarUrl(); + if (aav.IsAbsoluteUri) + embed.WithImageUrl(aav.ToString()); + } + else + return; + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public bool Log(ulong gid, ulong? cid, LogType type /*, string options*/) + { + ulong? channelId = null; + using (var uow = _db.GetDbContext()) + { + var logSetting = uow.LogSettingsFor(gid); + GuildLogSettings.AddOrUpdate(gid, _ => logSetting, (_, _) => logSetting); + switch (type) + { + case LogType.Other: + channelId = logSetting.LogOtherId = logSetting.LogOtherId is null ? cid : default; + break; + case LogType.MessageUpdated: + channelId = logSetting.MessageUpdatedId = logSetting.MessageUpdatedId is null ? cid : default; + break; + case LogType.MessageDeleted: + channelId = logSetting.MessageDeletedId = logSetting.MessageDeletedId is null ? cid : default; + //logSetting.DontLogBotMessageDeleted = (options == "nobot"); + break; + case LogType.UserJoined: + channelId = logSetting.UserJoinedId = logSetting.UserJoinedId is null ? cid : default; + break; + case LogType.UserLeft: + channelId = logSetting.UserLeftId = logSetting.UserLeftId is null ? cid : default; + break; + case LogType.UserBanned: + channelId = logSetting.UserBannedId = logSetting.UserBannedId is null ? cid : default; + break; + case LogType.UserUnbanned: + channelId = logSetting.UserUnbannedId = logSetting.UserUnbannedId is null ? cid : default; + break; + case LogType.UserUpdated: + channelId = logSetting.UserUpdatedId = logSetting.UserUpdatedId is null ? cid : default; + break; + case LogType.UserMuted: + channelId = logSetting.UserMutedId = logSetting.UserMutedId is null ? cid : default; + break; + case LogType.ChannelCreated: + channelId = logSetting.ChannelCreatedId = logSetting.ChannelCreatedId is null ? cid : default; + break; + case LogType.ChannelDestroyed: + channelId = logSetting.ChannelDestroyedId = logSetting.ChannelDestroyedId is null ? cid : default; + break; + case LogType.ChannelUpdated: + channelId = logSetting.ChannelUpdatedId = logSetting.ChannelUpdatedId is null ? cid : default; + break; + case LogType.UserPresence: + channelId = logSetting.LogUserPresenceId = logSetting.LogUserPresenceId is null ? cid : default; + break; + case LogType.VoicePresence: + channelId = logSetting.LogVoicePresenceId = logSetting.LogVoicePresenceId is null ? cid : default; + break; + case LogType.UserWarned: + channelId = logSetting.LogWarnsId = logSetting.LogWarnsId is null ? cid : default; + break; + case LogType.ThreadDeleted: + channelId = logSetting.ThreadDeletedId = logSetting.ThreadDeletedId is null ? cid : default; + break; + case LogType.ThreadCreated: + channelId = logSetting.ThreadCreatedId = logSetting.ThreadCreatedId is null ? cid : default; + break; + } + + uow.SaveChanges(); + } + + return channelId is not null; + } + + private void MuteCommands_UserMuted( + IGuildUser usr, + IUser mod, + MuteType muteType, + string reason) + => _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserMutedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserMuted)) is null) + return; + var mutes = string.Empty; + var mutedLocalized = GetText(logChannel.Guild, strs.muted_sn); + switch (muteType) + { + case MuteType.Voice: + mutes = "🔇 " + GetText(logChannel.Guild, strs.xmuted_voice(mutedLocalized, mod.ToString())); + break; + case MuteType.Chat: + mutes = "🔇 " + GetText(logChannel.Guild, strs.xmuted_text(mutedLocalized, mod.ToString())); + break; + case MuteType.All: + mutes = "🔇 " + + GetText(logChannel.Guild, strs.xmuted_text_and_voice(mutedLocalized, mod.ToString())); + break; + } + + var embed = _sender.CreateEmbed() + .WithAuthor(mutes) + .WithTitle($"{usr.Username}#{usr.Discriminator} | {usr.Id}") + .WithFooter(CurrentTime(usr.Guild)) + .WithOkColor(); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + + private void MuteCommands_UserUnmuted( + IGuildUser usr, + IUser mod, + MuteType muteType, + string reason) + => _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserMutedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserMuted)) is null) + return; + + var mutes = string.Empty; + var unmutedLocalized = GetText(logChannel.Guild, strs.unmuted_sn); + switch (muteType) + { + case MuteType.Voice: + mutes = "🔊 " + GetText(logChannel.Guild, strs.xmuted_voice(unmutedLocalized, mod.ToString())); + break; + case MuteType.Chat: + mutes = "🔊 " + GetText(logChannel.Guild, strs.xmuted_text(unmutedLocalized, mod.ToString())); + break; + case MuteType.All: + mutes = "🔊 " + + GetText(logChannel.Guild, + strs.xmuted_text_and_voice(unmutedLocalized, mod.ToString())); + break; + } + + var embed = _sender.CreateEmbed() + .WithAuthor(mutes) + .WithTitle($"{usr.Username}#{usr.Discriminator} | {usr.Id}") + .WithFooter($"{CurrentTime(usr.Guild)}") + .WithOkColor(); + + if (!string.IsNullOrWhiteSpace(reason)) + embed.WithDescription(reason); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + + public Task TriggeredAntiProtection(PunishmentAction action, ProtectionType protection, params IGuildUser[] users) + { + _ = Task.Run(async () => + { + try + { + if (users.Length == 0) + return; + + if (!GuildLogSettings.TryGetValue(users.First().Guild.Id, out var logSetting) + || logSetting.LogOtherId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(users.First().Guild, logSetting, LogType.Other)) is null) + return; + + var punishment = string.Empty; + switch (action) + { + case PunishmentAction.Mute: + punishment = "🔇 " + GetText(logChannel.Guild, strs.muted_pl).ToUpperInvariant(); + break; + case PunishmentAction.Kick: + punishment = "👢 " + GetText(logChannel.Guild, strs.kicked_pl).ToUpperInvariant(); + break; + case PunishmentAction.Softban: + punishment = "☣ " + GetText(logChannel.Guild, strs.soft_banned_pl).ToUpperInvariant(); + break; + case PunishmentAction.Ban: + punishment = "⛔️ " + GetText(logChannel.Guild, strs.banned_pl).ToUpperInvariant(); + break; + case PunishmentAction.RemoveRoles: + punishment = "⛔️ " + GetText(logChannel.Guild, strs.remove_roles_pl).ToUpperInvariant(); + break; + } + + var embed = _sender.CreateEmbed() + .WithAuthor($"🛡 Anti-{protection}") + .WithTitle(GetText(logChannel.Guild, strs.users) + " " + punishment) + .WithDescription(string.Join("\n", users.Select(u => u.ToString()))) + .WithFooter(CurrentTime(logChannel.Guild)) + .WithOkColor(); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private string GetRoleDeletedKey(ulong roleId) + => $"role_deleted_{roleId}"; + + private Task _client_RoleDeleted(SocketRole socketRole) + { + Serilog.Log.Information("Role deleted {RoleId}", socketRole.Id); + _memoryCache.Set(GetRoleDeletedKey(socketRole.Id), true, TimeSpan.FromMinutes(5)); + return Task.CompletedTask; + } + + private bool IsRoleDeleted(ulong roleId) + { + var isDeleted = _memoryCache.TryGetValue(GetRoleDeletedKey(roleId), out _); + return isDeleted; + } + + private Task _client_GuildUserUpdated(Cacheable optBefore, SocketGuildUser after) + { + _ = Task.Run(async () => + { + try + { + var before = await optBefore.GetOrDownloadAsync(); + + if (before is null) + return; + + if (!GuildLogSettings.TryGetValue(before.Guild.Id, out var logSetting) + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if (logSetting.UserUpdatedId is not null + && (logChannel = await TryGetLogChannel(before.Guild, logSetting, LogType.UserUpdated)) is not null) + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithFooter(CurrentTime(before.Guild)) + .WithTitle($"{before.Username}#{before.Discriminator} | {before.Id}"); + if (before.Nickname != after.Nickname) + { + embed.WithAuthor("👥 " + GetText(logChannel.Guild, strs.nick_change)) + .AddField(GetText(logChannel.Guild, strs.old_nick), + $"{before.Nickname}#{before.Discriminator}") + .AddField(GetText(logChannel.Guild, strs.new_nick), + $"{after.Nickname}#{after.Discriminator}"); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + else if (!before.Roles.SequenceEqual(after.Roles)) + { + if (before.Roles.Count < after.Roles.Count) + { + var diffRoles = after.Roles.Where(r => !before.Roles.Contains(r)).Select(r => r.Name); + embed.WithAuthor("⚔ " + GetText(logChannel.Guild, strs.user_role_add)) + .WithDescription(string.Join(", ", diffRoles).SanitizeMentions()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + else if (before.Roles.Count > after.Roles.Count) + { + await Task.Delay(1000); + var diffRoles = before.Roles.Where(r => !after.Roles.Contains(r) && !IsRoleDeleted(r.Id)) + .Select(r => r.Name) + .ToList(); + + if (diffRoles.Any()) + { + embed.WithAuthor("⚔ " + GetText(logChannel.Guild, strs.user_role_rem)) + .WithDescription(string.Join(", ", diffRoles).SanitizeMentions()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + } + } + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelUpdated(IChannel cbefore, IChannel cafter) + { + _ = Task.Run(async () => + { + try + { + if (cbefore is not IGuildChannel before) + return; + + var after = (IGuildChannel)cafter; + + if (!GuildLogSettings.TryGetValue(before.Guild.Id, out var logSetting) + || logSetting.ChannelUpdatedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(before.Guild, logSetting, LogType.ChannelUpdated)) is null) + return; + + var embed = _sender.CreateEmbed().WithOkColor().WithFooter(CurrentTime(before.Guild)); + + var beforeTextChannel = cbefore as ITextChannel; + var afterTextChannel = cafter as ITextChannel; + + if (before.Name != after.Name) + { + embed.WithTitle("ℹ️ " + GetText(logChannel.Guild, strs.ch_name_change)) + .WithDescription($"{after} | {after.Id}") + .AddField(GetText(logChannel.Guild, strs.ch_old_name), before.Name); + } + else if (beforeTextChannel?.Topic != afterTextChannel?.Topic) + { + embed.WithTitle("ℹ️ " + GetText(logChannel.Guild, strs.ch_topic_change)) + .WithDescription($"{after} | {after.Id}") + .AddField(GetText(logChannel.Guild, strs.old_topic), beforeTextChannel?.Topic ?? "-") + .AddField(GetText(logChannel.Guild, strs.new_topic), afterTextChannel?.Topic ?? "-"); + } + else + return; + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelDestroyed(IChannel ich) + { + _ = Task.Run(async () => + { + try + { + if (ich is not IGuildChannel ch) + return; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ChannelDestroyedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == ch.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ChannelDestroyed)) is null) + return; + + string title; + if (ch is IVoiceChannel) + title = GetText(logChannel.Guild, strs.voice_chan_destroyed); + else + title = GetText(logChannel.Guild, strs.text_chan_destroyed); + + await _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelCreated(IChannel ich) + { + _ = Task.Run(async () => + { + try + { + if (ich is not IGuildChannel ch) + return; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ChannelCreatedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ChannelCreated)) is null) + return; + string title; + if (ch is IVoiceChannel) + title = GetText(logChannel.Guild, strs.voice_chan_created); + else + title = GetText(logChannel.Guild, strs.text_chan_created); + + await _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserVoiceStateUpdated(SocketUser iusr, SocketVoiceState before, SocketVoiceState after) + { + _ = Task.Run(async () => + { + try + { + if (iusr is not IGuildUser usr || usr.IsBot) + return; + + var beforeVch = before.VoiceChannel; + var afterVch = after.VoiceChannel; + + if (beforeVch == afterVch) + return; + + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) + || logSetting.LogVoicePresenceId is null + || logSetting.LogIgnores.Any( + ilc => ilc.LogItemId == iusr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.VoicePresence)) is null) + return; + + var str = string.Empty; + if (beforeVch?.Guild == afterVch?.Guild) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vmoved("👤" + Format.Bold(usr.Username), + Format.Bold(beforeVch?.Name ?? ""), + Format.Bold(afterVch?.Name ?? ""))); + } + else if (beforeVch is null) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vjoined("👤" + Format.Bold(usr.Username), + Format.Bold(afterVch?.Name ?? ""))); + } + else if (afterVch is null) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vleft("👤" + Format.Bold(usr.Username), + Format.Bold(beforeVch.Name ?? ""))); + } + + if (!string.IsNullOrWhiteSpace(str)) + { + PresenceUpdates.AddOrUpdate(logChannel, + [str], + (_, list) => + { + list.Add(str); + return list; + }); + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserLeft(SocketGuild guild, SocketUser usr) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserLeftId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserLeft)) is null) + return; + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("❌ " + GetText(logChannel.Guild, strs.user_left)) + .WithDescription(usr.ToString()) + .AddField("Id", usr.Id.ToString()) + .WithFooter(CurrentTime(guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserJoined(IGuildUser usr) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserJoinedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserJoined)) is null) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("✅ " + GetText(logChannel.Guild, strs.user_joined)) + .WithDescription($"{usr.Mention} `{usr}`") + .AddField("Id", usr.Id.ToString()) + .AddField(GetText(logChannel.Guild, strs.joined_server), + $"{usr.JoinedAt?.ToString("dd.MM.yyyy HH:mm") ?? "?"}", + true) + .AddField(GetText(logChannel.Guild, strs.joined_discord), + $"{usr.CreatedAt:dd.MM.yyyy HH:mm}", + true) + .WithFooter(CurrentTime(usr.Guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserUnbanned(IUser usr, IGuild guild) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserUnbannedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserUnbanned)) is null) + return; + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("♻️ " + GetText(logChannel.Guild, strs.user_unbanned)) + .WithDescription(usr.ToString()!) + .AddField("Id", usr.Id.ToString()) + .WithFooter(CurrentTime(guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserBanned(IUser usr, IGuild guild) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserBannedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserBanned)) == null) + return; + + + string? reason = null; + try + { + var ban = await guild.GetBanAsync(usr); + reason = ban?.Reason; + } + catch + { + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("🚫 " + GetText(logChannel.Guild, strs.user_banned)) + .WithDescription(usr.ToString()!) + .AddField("Id", usr.Id.ToString()) + .AddField("Reason", string.IsNullOrWhiteSpace(reason) ? "-" : reason) + .WithFooter(CurrentTime(guild)); + + var avatarUrl = usr.GetAvatarUrl(); + + if (Uri.IsWellFormedUriString(avatarUrl, UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_MessageDeleted(Cacheable optMsg, Cacheable optCh) + { + _ = Task.Run(async () => + { + try + { + if (optMsg.Value is not IUserMessage msg || msg.IsAuthor(_client)) + return; + + if (_ignoreMessageIds.Contains(msg.Id)) + return; + + var ch = optCh.Value; + if (ch is not ITextChannel channel) + return; + + if (!GuildLogSettings.TryGetValue(channel.Guild.Id, out var logSetting) + || logSetting.MessageDeletedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == channel.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(channel.Guild, logSetting, LogType.MessageDeleted)) is null + || logChannel.Id == msg.Id) + return; + + var resolvedMessage = msg.Resolve(TagHandling.FullName); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("🗑 " + + GetText(logChannel.Guild, strs.msg_del(((ITextChannel)msg.Channel).Name))) + .WithDescription(msg.Author.ToString()!) + .AddField(GetText(logChannel.Guild, strs.content), + string.IsNullOrWhiteSpace(resolvedMessage) ? "-" : resolvedMessage) + .AddField("Id", msg.Id.ToString()) + .WithFooter(CurrentTime(channel.Guild)); + if (msg.Attachments.Any()) + { + embed.AddField(GetText(logChannel.Guild, strs.attachments), + string.Join(", ", msg.Attachments.Select(a => a.Url))); + } + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_MessageUpdated( + Cacheable optmsg, + SocketMessage imsg2, + ISocketMessageChannel ch) + { + _ = Task.Run(async () => + { + try + { + if (imsg2 is not IUserMessage after || after.IsAuthor(_client)) + return; + + if ((optmsg.HasValue ? optmsg.Value : null) is not IUserMessage before) + return; + + if (ch is not ITextChannel channel) + return; + + if (before.Content == after.Content) + return; + + if (before.Author.IsBot) + return; + + if (!GuildLogSettings.TryGetValue(channel.Guild.Id, out var logSetting) + || logSetting.MessageUpdatedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == channel.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(channel.Guild, logSetting, LogType.MessageUpdated)) is null + || logChannel.Id == after.Channel.Id) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("📝 " + + GetText(logChannel.Guild, + strs.msg_update(((ITextChannel)after.Channel).Name))) + .WithDescription(after.Author.ToString()!) + .AddField(GetText(logChannel.Guild, strs.old_msg), + string.IsNullOrWhiteSpace(before.Content) + ? "-" + : before.Resolve(TagHandling.FullName)) + .AddField(GetText(logChannel.Guild, strs.new_msg), + string.IsNullOrWhiteSpace(after.Content) ? "-" : after.Resolve(TagHandling.FullName)) + .AddField("Id", after.Id.ToString()) + .WithFooter(CurrentTime(channel.Guild)); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private async Task TryGetLogChannel(IGuild guild, LogSetting logSetting, LogType logChannelType) + { + ulong? id = null; + switch (logChannelType) + { + case LogType.Other: + id = logSetting.LogOtherId; + break; + case LogType.MessageUpdated: + id = logSetting.MessageUpdatedId; + break; + case LogType.MessageDeleted: + id = logSetting.MessageDeletedId; + break; + case LogType.UserJoined: + id = logSetting.UserJoinedId; + break; + case LogType.UserLeft: + id = logSetting.UserLeftId; + break; + case LogType.UserBanned: + id = logSetting.UserBannedId; + break; + case LogType.UserUnbanned: + id = logSetting.UserUnbannedId; + break; + case LogType.UserUpdated: + id = logSetting.UserUpdatedId; + break; + case LogType.ChannelCreated: + id = logSetting.ChannelCreatedId; + break; + case LogType.ChannelDestroyed: + id = logSetting.ChannelDestroyedId; + break; + case LogType.ChannelUpdated: + id = logSetting.ChannelUpdatedId; + break; + case LogType.UserPresence: + id = logSetting.LogUserPresenceId; + break; + case LogType.VoicePresence: + id = logSetting.LogVoicePresenceId; + break; + case LogType.UserMuted: + id = logSetting.UserMutedId; + break; + case LogType.UserWarned: + id = logSetting.LogWarnsId; + break; + case LogType.ThreadCreated: + id = logSetting.ThreadCreatedId; + break; + case LogType.ThreadDeleted: + id = logSetting.ThreadDeletedId; + break; + } + + if (id is null or 0) + { + UnsetLogSetting(guild.Id, logChannelType); + return null; + } + + var channel = await guild.GetTextChannelAsync(id.Value); + + if (channel is null) + { + UnsetLogSetting(guild.Id, logChannelType); + return null; + } + + return channel; + } + + private void UnsetLogSetting(ulong guildId, LogType logChannelType) + { + using var uow = _db.GetDbContext(); + var newLogSetting = uow.LogSettingsFor(guildId); + switch (logChannelType) + { + case LogType.Other: + newLogSetting.LogOtherId = null; + break; + case LogType.MessageUpdated: + newLogSetting.MessageUpdatedId = null; + break; + case LogType.MessageDeleted: + newLogSetting.MessageDeletedId = null; + break; + case LogType.UserJoined: + newLogSetting.UserJoinedId = null; + break; + case LogType.UserLeft: + newLogSetting.UserLeftId = null; + break; + case LogType.UserBanned: + newLogSetting.UserBannedId = null; + break; + case LogType.UserUnbanned: + newLogSetting.UserUnbannedId = null; + break; + case LogType.UserUpdated: + newLogSetting.UserUpdatedId = null; + break; + case LogType.UserMuted: + newLogSetting.UserMutedId = null; + break; + case LogType.ChannelCreated: + newLogSetting.ChannelCreatedId = null; + break; + case LogType.ChannelDestroyed: + newLogSetting.ChannelDestroyedId = null; + break; + case LogType.ChannelUpdated: + newLogSetting.ChannelUpdatedId = null; + break; + case LogType.UserPresence: + newLogSetting.LogUserPresenceId = null; + break; + case LogType.VoicePresence: + newLogSetting.LogVoicePresenceId = null; + break; + case LogType.UserWarned: + newLogSetting.LogWarnsId = null; + break; + } + + GuildLogSettings.AddOrUpdate(guildId, newLogSetting, (_, _) => newLogSetting); + uow.SaveChanges(); + } +} diff --git a/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs new file mode 100644 index 0000000..1632da5 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs @@ -0,0 +1,175 @@ +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + [NoPublicBot] + public partial class LogCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogServer(PermissionAction action) + { + await _service.LogServer(ctx.Guild.Id, ctx.Channel.Id, action.Value); + if (action.Value) + await Response().Confirm(strs.log_all).SendAsync(); + else + await Response().Confirm(strs.log_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore() + { + var settings = _service.GetGuildLogSettings(ctx.Guild.Id); + + var chs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.Channel).ToList() + ?? new List(); + var usrs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.User).ToList() + ?? new List(); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .AddField(GetText(strs.log_ignored_channels), + chs.Count == 0 + ? "-" + : string.Join('\n', chs.Select(x => $"{x.LogItemId} | <#{x.LogItemId}>"))) + .AddField(GetText(strs.log_ignored_users), + usrs.Count == 0 + ? "-" + : string.Join('\n', usrs.Select(x => $"{x.LogItemId} | <@{x.LogItemId}>"))); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore([Leftover] ITextChannel target) + { + var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.Channel); + + if (!removed) + { + await Response() + .Confirm( + strs.log_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + else + { + await Response() + .Confirm( + strs.log_not_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore([Leftover] IUser target) + { + var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.User); + + if (!removed) + { + await Response() + .Confirm(strs.log_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.log_not_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogEvents() + { + var logSetting = _service.GetGuildLogSettings(ctx.Guild.Id); + var str = string.Join("\n", + Enum.GetNames() + .Select(x => + { + var val = logSetting is null ? null : GetLogProperty(logSetting, Enum.Parse(x)); + if (val is not null) + return $"{Format.Bold(x)} <#{val}>"; + return Format.Bold(x); + })); + + await Response().Confirm(Format.Bold(GetText(strs.log_events)) + "\n" + str).SendAsync(); + } + + private static ulong? GetLogProperty(LogSetting l, LogType type) + { + switch (type) + { + case LogType.Other: + return l.LogOtherId; + case LogType.MessageUpdated: + return l.MessageUpdatedId; + case LogType.MessageDeleted: + return l.MessageDeletedId; + case LogType.UserJoined: + return l.UserJoinedId; + case LogType.UserLeft: + return l.UserLeftId; + case LogType.UserBanned: + return l.UserBannedId; + case LogType.UserUnbanned: + return l.UserUnbannedId; + case LogType.UserUpdated: + return l.UserUpdatedId; + case LogType.ChannelCreated: + return l.ChannelCreatedId; + case LogType.ChannelDestroyed: + return l.ChannelDestroyedId; + case LogType.ChannelUpdated: + return l.ChannelUpdatedId; + case LogType.UserPresence: + return l.LogUserPresenceId; + case LogType.VoicePresence: + return l.LogVoicePresenceId; + case LogType.UserMuted: + return l.UserMutedId; + case LogType.UserWarned: + return l.LogWarnsId; + case LogType.ThreadDeleted: + return l.ThreadDeletedId; + case LogType.ThreadCreated: + return l.ThreadCreatedId; + default: + return null; + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task Log(LogType type) + { + var val = _service.Log(ctx.Guild.Id, ctx.Channel.Id, type); + + if (val) + await Response().Confirm(strs.log(Format.Bold(type.ToString()))).SendAsync(); + else + await Response().Confirm(strs.log_stop(Format.Bold(type.ToString()))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs b/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs new file mode 100644 index 0000000..e1f2fea --- /dev/null +++ b/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs @@ -0,0 +1,95 @@ +#nullable disable +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class GuildTimezoneService : ITimezoneService, IReadyExecutor, IEService +{ + private readonly ConcurrentDictionary _timezones; + private readonly DbService _db; + private readonly IReplacementPatternStore _repStore; + + public GuildTimezoneService(IBot bot, DbService db, IReplacementPatternStore repStore) + { + _timezones = bot.AllGuildConfigs.Select(GetTimzezoneTuple) + .Where(x => x.Timezone is not null) + .ToDictionary(x => x.GuildId, x => x.Timezone) + .ToConcurrent(); + + _db = db; + _repStore = repStore; + + bot.JoinedGuild += Bot_JoinedGuild; + } + + private Task Bot_JoinedGuild(GuildConfig arg) + { + var (guildId, tz) = GetTimzezoneTuple(arg); + if (tz is not null) + _timezones.TryAdd(guildId, tz); + return Task.CompletedTask; + } + + private static (ulong GuildId, TimeZoneInfo Timezone) GetTimzezoneTuple(GuildConfig x) + { + TimeZoneInfo tz; + try + { + if (x.TimeZoneId is null) + tz = null; + else + tz = TimeZoneInfo.FindSystemTimeZoneById(x.TimeZoneId); + } + catch + { + tz = null; + } + + return (x.GuildId, Timezone: tz); + } + + public TimeZoneInfo GetTimeZoneOrDefault(ulong? guildId) + { + if (guildId is ulong gid && _timezones.TryGetValue(gid, out var tz)) + return tz; + + return null; + } + + public void SetTimeZone(ulong guildId, TimeZoneInfo tz) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + + gc.TimeZoneId = tz?.Id; + uow.SaveChanges(); + + if (tz is null) + _timezones.TryRemove(guildId, out tz); + else + _timezones.AddOrUpdate(guildId, tz, (_, _) => tz); + } + + public TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId) + => GetTimeZoneOrDefault(guildId) ?? TimeZoneInfo.Utc; + + public Task OnReadyAsync() + { + _repStore.Register("%server.time%", + (IGuild g) => + { + var to = TimeZoneInfo.Local; + if (g is not null) + { + to = GetTimeZoneOrDefault(g.Id) ?? TimeZoneInfo.Local; + } + + return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToString("HH:mm ") + + to.StandardName.GetInitials(); + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs b/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs new file mode 100644 index 0000000..a05789d --- /dev/null +++ b/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs @@ -0,0 +1,78 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class TimeZoneCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Timezones(int page = 1) + { + page--; + + if (page is < 0 or > 20) + return; + + var timezones = TimeZoneInfo.GetSystemTimeZones().OrderBy(x => x.BaseUtcOffset).ToArray(); + var timezonesPerPage = 20; + + var curTime = DateTimeOffset.UtcNow; + + var i = 0; + var timezoneStrings = timezones.Select(x => (x, ++i % 2 == 0)) + .Select(data => + { + var (tzInfo, flip) = data; + var nameStr = $"{tzInfo.Id,-30}"; + var offset = curTime.ToOffset(tzInfo.GetUtcOffset(curTime)) + .ToString("zzz"); + if (flip) + return $"{offset} {Format.Code(nameStr)}"; + return $"{Format.Code(offset)} {nameStr}"; + }) + .ToList(); + + + await Response() + .Paginated() + .Items(timezoneStrings) + .PageSize(timezonesPerPage) + .CurrentPage(page) + .Page((items, _) => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.timezones_available)) + .WithDescription(string.Join("\n", items))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Timezone() + => await Response().Confirm(strs.timezone_guild(_service.GetTimeZoneOrUtc(ctx.Guild.Id))).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task Timezone([Leftover] string id) + { + TimeZoneInfo tz; + try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); } + catch { tz = null; } + + + if (tz is null) + { + await Response().Error(strs.timezone_not_found).SendAsync(); + return; + } + + _service.SetTimeZone(ctx.Guild.Id, tz); + + await Response().Confirm(tz.ToString()).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs b/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs new file mode 100644 index 0000000..27a3f12 --- /dev/null +++ b/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs @@ -0,0 +1,960 @@ +#nullable disable +using CommandLine; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class UserPunishCommands : EllieModule + { + public enum AddRole + { + AddRole + } + + private readonly MuteService _mute; + + public UserPunishCommands(MuteService mute) + { + _mute = mute; + } + + private async Task CheckRoleHierarchy(IGuildUser target) + { + var curUser = ((SocketGuild)ctx.Guild).CurrentUser; + var ownerId = ctx.Guild.OwnerId; + var modMaxRole = ((IGuildUser)ctx.User).GetRoles().Max(r => r.Position); + var targetMaxRole = target.GetRoles().Max(r => r.Position); + var botMaxRole = curUser.GetRoles().Max(r => r.Position); + // bot can't punish a user who is higher in the hierarchy. Discord will return 403 + // moderator can be owner, in which case role hierarchy doesn't matter + // otherwise, moderator has to have a higher role + if (botMaxRole <= targetMaxRole + || (ctx.User.Id != ownerId && targetMaxRole >= modMaxRole) + || target.Id == ownerId) + { + await Response().Error(strs.hierarchy).SendAsync(); + return false; + } + + return true; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public Task Warn(IGuildUser user, [Leftover] string reason = null) + => Warn(1, user, reason); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task Warn(int weight, IGuildUser user, [Leftover] string reason = null) + { + if (weight <= 0) + return; + + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + try + { + await _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithErrorColor() + .WithDescription(GetText(strs.warned_on(ctx.Guild.ToString()))) + .AddField(GetText(strs.moderator), ctx.User.ToString()) + .AddField(GetText(strs.reason), reason ?? "-")) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + WarningPunishment punishment; + try + { + punishment = await _service.Warn(ctx.Guild, user.Id, ctx.User, weight, reason); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception occured while warning a user"); + var errorEmbed = _sender.CreateEmbed().WithErrorColor() + .WithDescription(GetText(strs.cant_apply_punishment)); + + if (dmFailed) + errorEmbed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(errorEmbed).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor(); + if (punishment is null) + embed.WithDescription(GetText(strs.user_warned(Format.Bold(user.ToString())))); + else + { + embed.WithDescription(GetText(strs.user_warned_and_punished(Format.Bold(user.ToString()), + Format.Bold(punishment.Punishment.ToString())))); + } + + if (dmFailed) + embed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [EllieOptions] + [Priority(1)] + public async Task WarnExpire() + { + var expireDays = await _service.GetWarnExpire(ctx.Guild.Id); + + if (expireDays == 0) + await Response().Confirm(strs.warns_dont_expire).SendAsync(); + else + await Response().Error(strs.warns_expire_in(expireDays)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [EllieOptions] + [Priority(2)] + public async Task WarnExpire(int days, params string[] args) + { + if (days is < 0 or > 366) + return; + + var opts = OptionsParser.ParseFrom(args); + + await ctx.Channel.TriggerTypingAsync(); + + await _service.WarnExpireAsync(ctx.Guild.Id, days, opts.Delete); + if (days == 0) + { + await Response().Confirm(strs.warn_expire_reset).SendAsync(); + return; + } + + if (opts.Delete) + await Response().Confirm(strs.warn_expire_set_delete(Format.Bold(days.ToString()))).SendAsync(); + else + await Response().Confirm(strs.warn_expire_set_clear(Format.Bold(days.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(2)] + public Task Warnlog(int page, [Leftover] IGuildUser user = null) + { + user ??= (IGuildUser)ctx.User; + + return Warnlog(page, user.Id); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(3)] + public Task Warnlog(IGuildUser user = null) + { + user ??= (IGuildUser)ctx.User; + + return ctx.User.Id == user.Id || ((IGuildUser)ctx.User).GuildPermissions.BanMembers + ? Warnlog(user.Id) + : Task.CompletedTask; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(0)] + public Task Warnlog(int page, ulong userId) + => InternalWarnlog(userId, page - 1); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task Warnlog(ulong userId) + => InternalWarnlog(userId, 0); + + private async Task InternalWarnlog(ulong userId, int inputPage) + { + if (inputPage < 0) + return; + + var allWarnings = _service.UserWarnings(ctx.Guild.Id, userId); + + await Response() + .Paginated() + .Items(allWarnings) + .PageSize(9) + .CurrentPage(inputPage) + .Page((warnings, page) => + { + var user = (ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString(); + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.warnlog_for(user))); + + if (!warnings.Any()) + embed.WithDescription(GetText(strs.warnings_none)); + else + { + var descText = GetText(strs.warn_count( + Format.Bold(warnings.Where(x => !x.Forgiven).Sum(x => x.Weight).ToString()), + Format.Bold(warnings.Sum(x => x.Weight).ToString()))); + + embed.WithDescription(descText); + + var i = page * 9; + foreach (var w in warnings) + { + i++; + var name = GetText(strs.warned_on_by(w.DateAdded?.ToString("dd.MM.yyy"), + w.DateAdded?.ToString("HH:mm"), + w.Moderator)); + + if (w.Forgiven) + name = $"{Format.Strikethrough(name)} {GetText(strs.warn_cleared_by(w.ForgivenBy))}"; + + + embed.AddField($"#`{i}` " + name, + Format.Code(GetText(strs.warn_weight(w.Weight))) + '\n' + w.Reason.TrimTo(1000)); + } + } + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnlogAll(int page = 1) + { + if (--page < 0) + return; + var allWarnings = _service.WarnlogAll(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allWarnings) + .PageSize(15) + .CurrentPage(page) + .Page((warnings, _) => + { + var ws = warnings + .Select(x => + { + var all = x.Count(); + var forgiven = x.Count(y => y.Forgiven); + var total = all - forgiven; + var usr = ((SocketGuild)ctx.Guild).GetUser(x.Key); + return (usr?.ToString() ?? x.Key.ToString()) + + $" | {total} ({all} - {forgiven})"; + }); + + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.warnings_list)) + .WithDescription(string.Join("\n", ws)); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public Task Warnclear(IGuildUser user, int index = 0) + => Warnclear(user.Id, index); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task Warnclear(ulong userId, int index = 0) + { + if (index < 0) + return; + var success = await _service.WarnClearAsync(ctx.Guild.Id, userId, index, ctx.User.ToString()); + var userStr = Format.Bold((ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString()); + if (index == 0) + await Response().Error(strs.warnings_cleared(userStr)).SendAsync(); + else + { + if (success) + await Response().Confirm(strs.warning_cleared(Format.Bold(index.ToString()), userStr)).SendAsync(); + else + await Response().Error(strs.warning_clear_fail).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(1)] + public async Task WarnPunish( + int number, + AddRole _, + IRole role, + StoopidTime time = null) + { + var punish = PunishmentAction.AddRole; + + if (ctx.Guild.OwnerId != ctx.User.Id + && role.Position >= ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position)) + { + await Response().Error(strs.role_too_high).SendAsync(); + return; + } + + var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time, role); + + if (!success) + return; + + if (time is null) + { + await Response() + .Confirm(strs.warn_punish_set(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.warn_punish_set_timed(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()), + Format.Bold(time.Input))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnPunish(int number, PunishmentAction punish, StoopidTime time = null) + { + // this should never happen. Addrole has its own method with higher priority + // also disallow warn punishment for getting warned + if (punish is PunishmentAction.AddRole or PunishmentAction.Warn) + return; + + // you must specify the time for timeout + if (punish is PunishmentAction.TimeOut && time is null) + return; + + var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time); + + if (!success) + return; + + if (time is null) + { + await Response() + .Confirm(strs.warn_punish_set(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.warn_punish_set_timed(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()), + Format.Bold(time.Input))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnPunish(int number) + { + if (!_service.WarnPunishRemove(ctx.Guild.Id, number)) + return; + + await Response().Confirm(strs.warn_punish_rem(Format.Bold(number.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WarnPunishList() + { + var ps = _service.WarnPunishList(ctx.Guild.Id); + + string list; + if (ps.Any()) + { + list = string.Join("\n", + ps.Select(x + => $"{x.Count} -> {x.Punishment} {(x.Punishment == PunishmentAction.AddRole ? $"<@&{x.RoleId}>" : "")} {(x.Time <= 0 ? "" : x.Time + "m")} ")); + } + else + list = GetText(strs.warnpl_none); + + await Response().Confirm(GetText(strs.warn_punish_list), list).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task Ban(StoopidTime time, IUser user, [Leftover] string msg = null) + => Ban(time, user.Id, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public async Task Ban(StoopidTime time, ulong userId, [Leftover] string msg = null) + { + if (time.Time > TimeSpan.FromDays(49)) + return; + + var guildUser = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + + + if (guildUser is not null && !await CheckRoleHierarchy(guildUser)) + return; + + var dmFailed = false; + + if (guildUser is not null) + { + try + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg)); + var smartText = + await _service.GetBanUserDmEmbed(Context, guildUser, defaultMessage, msg, time.Time); + if (smartText is not null) + await Response().User(guildUser).Text(smartText).SendAsync(); + } + catch + { + dmFailed = true; + } + } + + var user = await ctx.Client.GetUserAsync(userId); + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await _mute.TimedBan(ctx.Guild, userId, time.Time, (ctx.User + " | " + msg).TrimTo(512), banPrune); + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField(GetText(strs.username), user?.ToString() ?? userId.ToString(), true) + .AddField("ID", userId.ToString(), true) + .AddField(GetText(strs.duration), + time.Time.ToPrettyStringHm(), + true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public async Task Ban(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + { + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(userId, banPrune, (ctx.User + " | " + msg).TrimTo(512)); + + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField("ID", userId.ToString(), true)) + .SendAsync(); + } + else + await Ban(user, msg); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(2)] + public async Task Ban(IGuildUser user, [Leftover] string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg)); + var embed = await _service.GetBanUserDmEmbed(Context, user, defaultMessage, msg, null); + if (embed is not null) + await Response().User(user).Text(embed).SendAsync(); + } + catch + { + dmFailed = true; + } + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(user, banPrune, (ctx.User + " | " + msg).TrimTo(512)); + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanPrune(int days) + { + if (days < 0 || days > 7) + { + await Response().Error(strs.invalid_input).SendAsync(); + return; + } + + await _service.SetBanPruneAsync(ctx.Guild.Id, days); + + if (days == 0) + await Response().Confirm(strs.ban_prune_disabled).SendAsync(); + else + await Response().Confirm(strs.ban_prune(days)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanMessage([Leftover] string message = null) + { + if (message is null) + { + var template = _service.GetBanTemplate(ctx.Guild.Id); + if (template is null) + { + await Response().Confirm(strs.banmsg_default).SendAsync(); + return; + } + + await Response().Confirm(template).SendAsync(); + return; + } + + _service.SetBanTemplate(ctx.Guild.Id, message); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanMsgReset() + { + _service.SetBanTemplate(ctx.Guild.Id, null); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public Task BanMessageTest([Leftover] string reason = null) + => InternalBanMessageTest(reason, null); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task BanMessageTest(StoopidTime duration, [Leftover] string reason = null) + => InternalBanMessageTest(reason, duration.Time); + + private async Task InternalBanMessageTest(string reason, TimeSpan? duration) + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), reason)); + var smartText = await _service.GetBanUserDmEmbed(Context, + (IGuildUser)ctx.User, + defaultMessage, + reason, + duration); + + if (smartText is null) + await Response().Confirm(strs.banmsg_disabled).SendAsync(); + else + { + try + { + await Response().User(ctx.User).Text(smartText).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.unable_to_dm_user).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Unban([Leftover] string user) + { + var bans = await ctx.Guild.GetBansAsync().FlattenAsync(); + + var bun = bans.FirstOrDefault(x => x.User.ToString()!.ToLowerInvariant() == user.ToLowerInvariant()); + + if (bun is null) + { + await Response().Error(strs.user_not_found).SendAsync(); + return; + } + + await UnbanInternal(bun.User); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Unban(ulong userId) + { + var bun = await ctx.Guild.GetBanAsync(userId); + + if (bun is null) + { + await Response().Error(strs.user_not_found).SendAsync(); + return; + } + + await UnbanInternal(bun.User); + } + + private async Task UnbanInternal(IUser user) + { + await ctx.Guild.RemoveBanAsync(user); + + await Response().Confirm(strs.unbanned_user(Format.Bold(user.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.BanMembers)] + public Task Softban(IGuildUser user, [Leftover] string msg = null) + => SoftbanInternal(user, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Softban(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + return; + + await SoftbanInternal(user, msg); + } + + private async Task SoftbanInternal(IGuildUser user, [Leftover] string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + await Response() + .Channel(await user.CreateDMChannelAsync()) + .Error(strs.sbdm(Format.Bold(ctx.Guild.Name), msg)) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(user, banPrune, ("Softban | " + ctx.User + " | " + msg).TrimTo(512)); + try { await ctx.Guild.RemoveBanAsync(user); } + catch { await ctx.Guild.RemoveBanAsync(user); } + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("☣ " + GetText(strs.sb_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers)] + [BotPerm(GuildPerm.KickMembers)] + [Priority(1)] + public Task Kick(IGuildUser user, [Leftover] string msg = null) + => KickInternal(user, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers)] + [BotPerm(GuildPerm.KickMembers)] + [Priority(0)] + public async Task Kick(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + return; + + await KickInternal(user, msg); + } + + private async Task KickInternal(IGuildUser user, string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + await Response() + .Channel(await user.CreateDMChannelAsync()) + .Error(GetText(strs.kickdm(Format.Bold(ctx.Guild.Name), msg))) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + await user.KickAsync((ctx.User + " | " + msg).TrimTo(512)); + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.kicked_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ModerateMembers)] + [BotPerm(GuildPerm.ModerateMembers)] + [Priority(2)] + public async Task Timeout(IUser globalUser, StoopidTime time, [Leftover] string msg = null) + { + var user = await ctx.Guild.GetUserAsync(globalUser.Id); + + if (user is null) + return; + + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + var dmMessage = GetText(strs.timeoutdm(Format.Bold(ctx.Guild.Name), msg)); + await _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithPendingColor() + .WithDescription(dmMessage)) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + await user.SetTimeOutAsync(time.Time); + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("⏳ " + GetText(strs.timedout_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Ratelimit(30)] + public async Task MassBan(params string[] userStrings) + { + if (userStrings.Length == 0) + return; + + var missing = new List(); + var banning = new HashSet(); + + await ctx.Channel.TriggerTypingAsync(); + foreach (var userStr in userStrings) + { + if (ulong.TryParse(userStr, out var userId)) + { + IUser user = await ctx.Guild.GetUserAsync(userId) + ?? await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, + userId); + + if (user is null) + { + // if IGuildUser is null, try to get IUser + user = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(userId); + + // only add to missing if *still* null + if (user is null) + { + missing.Add(userStr); + continue; + } + } + + //Hierachy checks only if the user is in the guild + if (user is IGuildUser gu && !await CheckRoleHierarchy(gu)) + return; + + banning.Add(user); + } + else + missing.Add(userStr); + } + + var missStr = string.Join("\n", missing); + if (string.IsNullOrWhiteSpace(missStr)) + missStr = "-"; + + var toSend = _sender.CreateEmbed() + .WithDescription(GetText(strs.mass_ban_in_progress(banning.Count))) + .AddField(GetText(strs.invalid(missing.Count)), missStr) + .WithPendingColor(); + + var banningMessage = await Response().Embed(toSend).SendAsync(); + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + foreach (var toBan in banning) + { + try + { + await ctx.Guild.AddBanAsync(toBan.Id, banPrune, $"{ctx.User} | Massban"); + } + catch (Exception ex) + { + Log.Warning(ex, "Error banning {User} user in {GuildId} server", toBan.Id, ctx.Guild.Id); + } + } + + await banningMessage.ModifyAsync(x => x.Embed = _sender.CreateEmbed() + .WithDescription( + GetText(strs.mass_ban_completed(banning.Count()))) + .AddField(GetText(strs.invalid(missing.Count)), missStr) + .WithOkColor() + .Build()); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [OwnerOnly] + public async Task MassKill([Leftover] string people) + { + if (string.IsNullOrWhiteSpace(people)) + return; + + var (bans, missing) = _service.MassKill((SocketGuild)ctx.Guild, people); + + var missStr = string.Join("\n", missing); + if (string.IsNullOrWhiteSpace(missStr)) + missStr = "-"; + + //send a message but don't wait for it + var banningMessageTask = Response() + .Embed(_sender.CreateEmbed() + .WithDescription( + GetText(strs.mass_kill_in_progress(bans.Count()))) + .AddField(GetText(strs.invalid(missing)), missStr) + .WithPendingColor()) + .SendAsync(); + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + //do the banning + await Task.WhenAll(bans.Where(x => x.Id.HasValue) + .Select(x => ctx.Guild.AddBanAsync(x.Id.Value, + banPrune, + x.Reason, + new() + { + RetryMode = RetryMode.AlwaysRetry + }))); + + //wait for the message and edit it + var banningMessage = await banningMessageTask; + + await banningMessage.ModifyAsync(x => x.Embed = _sender.CreateEmbed() + .WithDescription( + GetText(strs.mass_kill_completed(bans.Count()))) + .AddField(GetText(strs.invalid(missing)), missStr) + .WithOkColor() + .Build()); + } + + public class WarnExpireOptions : IEllieCommandOptions + { + [Option('d', "delete", Default = false, HelpText = "Delete warnings instead of clearing them.")] + public bool Delete { get; set; } = false; + + public void NormalizeOptions() + { + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs b/src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs new file mode 100644 index 0000000..cdc9900 --- /dev/null +++ b/src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs @@ -0,0 +1,597 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; +using Newtonsoft.Json; + +namespace EllieBot.Modules.Administration.Services; + +public class UserPunishService : IEService, IReadyExecutor +{ + private readonly MuteService _mute; + private readonly DbService _db; + private readonly BlacklistService _blacklistService; + private readonly BotConfigService _bcs; + private readonly DiscordSocketClient _client; + private readonly IReplacementService _repSvc; + + public event Func OnUserWarned = static delegate { return Task.CompletedTask; }; + + public UserPunishService( + MuteService mute, + DbService db, + BlacklistService blacklistService, + BotConfigService bcs, + DiscordSocketClient client, + IReplacementService repSvc) + { + _mute = mute; + _db = db; + _blacklistService = blacklistService; + _bcs = bcs; + _client = client; + _repSvc = repSvc; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var expiryTimer = new PeriodicTimer(TimeSpan.FromHours(12)); + do + { + try + { + await CheckAllWarnExpiresAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Unexpected error while checking for warn expiries: {ErrorMessage}", ex.Message); + } + } while (await expiryTimer.WaitForNextTickAsync()); + } + + public async Task Warn( + IGuild guild, + ulong userId, + IUser mod, + long weight, + string reason) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(weight); + + var modName = mod.ToString(); + + if (string.IsNullOrWhiteSpace(reason)) + reason = "-"; + + var guildId = guild.Id; + + var warn = new Warning + { + UserId = userId, + GuildId = guildId, + Forgiven = false, + Reason = reason, + Moderator = modName, + Weight = weight + }; + + long previousCount; + List ps; + await using (var uow = _db.GetDbContext()) + { + ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + + previousCount = uow.Set().ForId(guildId, userId) + .Where(w => !w.Forgiven && w.UserId == userId) + .Sum(x => x.Weight); + + uow.Set().Add(warn); + + await uow.SaveChangesAsync(); + } + + _ = OnUserWarned(warn); + + var totalCount = previousCount + weight; + + var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount) + .MaxBy(x => x.Count); + + if (p is not null) + { + var user = await guild.GetUserAsync(userId); + if (user is null) + return null; + + await ApplyPunishment(guild, user, mod, p.Punishment, p.Time, p.RoleId, "Warned too many times."); + return p; + } + + return null; + } + + public async Task ApplyPunishment( + IGuild guild, + IGuildUser user, + IUser mod, + PunishmentAction p, + int minutes, + ulong? roleId, + string reason) + { + if (!await CheckPermission(guild, p)) + return; + + int banPrune; + switch (p) + { + case PunishmentAction.Mute: + if (minutes == 0) + await _mute.MuteUser(user, mod, reason: reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), reason: reason); + break; + case PunishmentAction.VoiceMute: + if (minutes == 0) + await _mute.MuteUser(user, mod, MuteType.Voice, reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Voice, reason); + break; + case PunishmentAction.ChatMute: + if (minutes == 0) + await _mute.MuteUser(user, mod, MuteType.Chat, reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Chat, reason); + break; + case PunishmentAction.Kick: + await user.KickAsync(reason); + break; + case PunishmentAction.Ban: + banPrune = await GetBanPruneAsync(user.GuildId) ?? 7; + if (minutes == 0) + await guild.AddBanAsync(user, reason: reason, pruneDays: banPrune); + else + await _mute.TimedBan(user.Guild, user.Id, TimeSpan.FromMinutes(minutes), reason, banPrune); + break; + case PunishmentAction.Softban: + banPrune = await GetBanPruneAsync(user.GuildId) ?? 7; + await guild.AddBanAsync(user, banPrune, $"Softban | {reason}"); + try + { + await guild.RemoveBanAsync(user); + } + catch + { + await guild.RemoveBanAsync(user); + } + + break; + case PunishmentAction.RemoveRoles: + await user.RemoveRolesAsync(user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole)); + break; + case PunishmentAction.AddRole: + if (roleId is null) + return; + var role = guild.GetRole(roleId.Value); + if (role is not null) + { + if (minutes == 0) + await user.AddRoleAsync(role); + else + await _mute.TimedRole(user, TimeSpan.FromMinutes(minutes), reason, role); + } + else + { + Log.Warning("Can't find role {RoleId} on server {GuildId} to apply punishment", + roleId.Value, + guild.Id); + } + + break; + case PunishmentAction.Warn: + await Warn(guild, user.Id, mod, 1, reason); + break; + case PunishmentAction.TimeOut: + await user.SetTimeOutAsync(TimeSpan.FromMinutes(minutes)); + break; + } + } + + /// + /// Used to prevent the bot from hitting 403's when it needs to + /// apply punishments with insufficient permissions + /// + /// Guild the punishment is applied in + /// Punishment to apply + /// Whether the bot has sufficient permissions + private async Task CheckPermission(IGuild guild, PunishmentAction punish) + { + var botUser = await guild.GetCurrentUserAsync(); + switch (punish) + { + case PunishmentAction.Mute: + return botUser.GuildPermissions.MuteMembers && botUser.GuildPermissions.ManageRoles; + case PunishmentAction.Kick: + return botUser.GuildPermissions.KickMembers; + case PunishmentAction.Ban: + return botUser.GuildPermissions.BanMembers; + case PunishmentAction.Softban: + return botUser.GuildPermissions.BanMembers; // ban + unban + case PunishmentAction.RemoveRoles: + return botUser.GuildPermissions.ManageRoles; + case PunishmentAction.ChatMute: + return botUser.GuildPermissions.ManageRoles; // adds ellie-mute role + case PunishmentAction.VoiceMute: + return botUser.GuildPermissions.MuteMembers; + case PunishmentAction.AddRole: + return botUser.GuildPermissions.ManageRoles; + case PunishmentAction.TimeOut: + return botUser.GuildPermissions.ModerateMembers; + default: + return true; + } + } + + public async Task CheckAllWarnExpiresAsync() + { + await using var uow = _db.GetDbContext(); + var cleared = await uow.Set() + .Where(x => uow.Set() + .Any(y => y.GuildId == x.GuildId + && y.WarnExpireHours > 0 + && y.WarnExpireAction == WarnExpireAction.Clear) + && x.Forgiven == false + && x.DateAdded + < DateTime.UtcNow.AddHours(-uow.Set() + .Where(y => x.GuildId == y.GuildId) + .Select(y => y.WarnExpireHours) + .First())) + .UpdateAsync(_ => new() + { + Forgiven = true, + ForgivenBy = "expiry" + }); + + var deleted = await uow.Set() + .Where(x => uow.Set() + .Any(y => y.GuildId == x.GuildId + && y.WarnExpireHours > 0 + && y.WarnExpireAction == WarnExpireAction.Delete) + && x.DateAdded + < DateTime.UtcNow.AddHours(-uow.Set() + .Where(y => x.GuildId == y.GuildId) + .Select(y => y.WarnExpireHours) + .First())) + .DeleteAsync(); + + if (cleared > 0 || deleted > 0) + { + Log.Information("Cleared {ClearedWarnings} warnings and deleted {DeletedWarnings} warnings due to expiry", + cleared, + deleted); + } + + await uow.SaveChangesAsync(); + } + + public async Task CheckWarnExpiresAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, inc => inc); + + if (config.WarnExpireHours == 0) + return; + + if (config.WarnExpireAction == WarnExpireAction.Clear) + { + await uow.Set() + .Where(x => x.GuildId == guildId + && x.Forgiven == false + && x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours)) + .UpdateAsync(_ => new() + { + Forgiven = true, + ForgivenBy = "expiry" + }); + } + else if (config.WarnExpireAction == WarnExpireAction.Delete) + { + await uow.Set() + .Where(x => x.GuildId == guildId + && x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours)) + .DeleteAsync(); + } + + await uow.SaveChangesAsync(); + } + + public Task GetWarnExpire(ulong guildId) + { + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + return Task.FromResult(config.WarnExpireHours / 24); + } + + public async Task WarnExpireAsync(ulong guildId, int days, bool delete) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, inc => inc); + + config.WarnExpireHours = days * 24; + config.WarnExpireAction = delete ? WarnExpireAction.Delete : WarnExpireAction.Clear; + await uow.SaveChangesAsync(); + + // no need to check for warn expires + if (config.WarnExpireHours == 0) + return; + } + + await CheckWarnExpiresAsync(guildId); + } + + public IGrouping[] WarnlogAll(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetForGuild(gid).GroupBy(x => x.UserId).ToArray(); + } + + public Warning[] UserWarnings(ulong gid, ulong userId) + { + using var uow = _db.GetDbContext(); + return uow.Set().ForId(gid, userId); + } + + public async Task WarnClearAsync( + ulong guildId, + ulong userId, + int index, + string moderator) + { + var toReturn = true; + await using var uow = _db.GetDbContext(); + if (index == 0) + await uow.Set().ForgiveAll(guildId, userId, moderator); + else + toReturn = uow.Set().Forgive(guildId, userId, moderator, index - 1); + await uow.SaveChangesAsync(); + return toReturn; + } + + public bool WarnPunish( + ulong guildId, + int number, + PunishmentAction punish, + StoopidTime time, + IRole role = null) + { + // these 3 don't make sense with time + if (punish is PunishmentAction.Softban or PunishmentAction.Kick or PunishmentAction.RemoveRoles + && time is not null) + return false; + if (number <= 0 || (time is not null && time.Time > TimeSpan.FromDays(49))) + return false; + + if (punish is PunishmentAction.AddRole && role is null) + return false; + + if (punish is PunishmentAction.TimeOut && time is null) + return false; + + using var uow = _db.GetDbContext(); + var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + var toDelete = ps.Where(x => x.Count == number); + + uow.RemoveRange(toDelete); + + ps.Add(new() + { + Count = number, + Punishment = punish, + Time = (int?)time?.Time.TotalMinutes ?? 0, + RoleId = punish == PunishmentAction.AddRole ? role!.Id : default(ulong?) + }); + uow.SaveChanges(); + return true; + } + + public bool WarnPunishRemove(ulong guildId, int number) + { + if (number <= 0) + return false; + + using var uow = _db.GetDbContext(); + var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + var p = ps.FirstOrDefault(x => x.Count == number); + + if (p is not null) + { + uow.Remove(p); + uow.SaveChanges(); + } + + return true; + } + + public WarningPunishment[] WarnPunishList(ulong guildId) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(guildId, gc => gc.Include(x => x.WarnPunishments)) + .WarnPunishments.OrderBy(x => x.Count) + .ToArray(); + } + + public (IReadOnlyCollection<(string Original, ulong? Id, string Reason)> Bans, int Missing) MassKill( + SocketGuild guild, + string people) + { + var gusers = guild.Users; + //get user objects and reasons + var bans = people.Split("\n") + .Select(x => + { + var split = x.Trim().Split(" "); + + var reason = string.Join(" ", split.Skip(1)); + + if (ulong.TryParse(split[0], out var id)) + return (Original: split[0], Id: id, Reason: reason); + + return (Original: split[0], + gusers.FirstOrDefault(u => u.ToString().ToLowerInvariant() == x)?.Id, + Reason: reason); + }) + .ToArray(); + + //if user is null, means that person couldn't be found + var missing = bans.Count(x => !x.Id.HasValue); + + //get only data for found users + var found = bans.Where(x => x.Id.HasValue).Select(x => x.Id.Value).ToList(); + + _ = _blacklistService.BlacklistUsers(found); + + return (bans, missing); + } + + public string GetBanTemplate(ulong guildId) + { + using var uow = _db.GetDbContext(); + var template = uow.Set().AsQueryable().FirstOrDefault(x => x.GuildId == guildId); + return template?.Text; + } + + public void SetBanTemplate(ulong guildId, string text) + { + using var uow = _db.GetDbContext(); + var template = uow.Set().AsQueryable().FirstOrDefault(x => x.GuildId == guildId); + + if (text is null) + { + if (template is null) + return; + + uow.Remove(template); + } + else if (template is null) + { + uow.Set().Add(new() + { + GuildId = guildId, + Text = text + }); + } + else + template.Text = text; + + uow.SaveChanges(); + } + + public async Task SetBanPruneAsync(ulong guildId, int? pruneDays) + { + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + Text = null, + DateAdded = DateTime.UtcNow, + PruneDays = pruneDays + }, + old => new() + { + PruneDays = pruneDays + }, + () => new() + { + GuildId = guildId + }); + } + + public async Task GetBanPruneAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.Set() + .Where(x => x.GuildId == guildId) + .Select(x => x.PruneDays) + .FirstOrDefaultAsyncLinqToDB(); + } + + public Task GetBanUserDmEmbed( + ICommandContext context, + IGuildUser target, + string defaultMessage, + string banReason, + TimeSpan? duration) + => GetBanUserDmEmbed((DiscordSocketClient)context.Client, + (SocketGuild)context.Guild, + (IGuildUser)context.User, + target, + defaultMessage, + banReason, + duration); + + public async Task GetBanUserDmEmbed( + DiscordSocketClient client, + SocketGuild guild, + IGuildUser moderator, + IGuildUser target, + string defaultMessage, + string banReason, + TimeSpan? duration) + { + var template = GetBanTemplate(guild.Id); + + banReason = string.IsNullOrWhiteSpace(banReason) ? "-" : banReason; + + var repCtx = new ReplacementContext(client, guild) + .WithOverride("%ban.mod%", () => moderator.ToString()) + .WithOverride("%ban.mod.fullname%", () => moderator.ToString()) + .WithOverride("%ban.mod.name%", () => moderator.Username) + .WithOverride("%ban.mod.discrim%", () => moderator.Discriminator) + .WithOverride("%ban.user%", () => target.ToString()) + .WithOverride("%ban.user.fullname%", () => target.ToString()) + .WithOverride("%ban.user.name%", () => target.Username) + .WithOverride("%ban.user.discrim%", () => target.Discriminator) + .WithOverride("%reason%", () => banReason) + .WithOverride("%ban.reason%", () => banReason) + .WithOverride("%ban.duration%", + () => duration?.ToString(@"d\.hh\:mm") ?? "perma"); + + + // if template isn't set, use the old message style + if (string.IsNullOrWhiteSpace(template)) + { + template = JsonConvert.SerializeObject(new + { + color = _bcs.Data.Color.Error.PackedValue >> 8, + description = defaultMessage + }); + } + // if template is set to "-" do not dm the user + else if (template == "-") + return default; + // if template is an embed, send that embed with replacements + // otherwise, treat template as a regular string with replacements + else if (SmartText.CreateFrom(template) is not { IsEmbed: true } or { IsEmbedArray: true }) + { + template = JsonConvert.SerializeObject(new + { + color = _bcs.Data.Color.Error.PackedValue >> 8, + description = template + }); + } + + var output = SmartText.CreateFrom(template); + return await _repSvc.ReplaceAsync(output, repCtx); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs b/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs new file mode 100644 index 0000000..a5f547a --- /dev/null +++ b/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs @@ -0,0 +1,77 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class VcRoleCommands : EllieModule + { + [Cmd] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task VcRoleRm(ulong vcId) + { + if (_service.RemoveVcRole(ctx.Guild.Id, vcId)) + await Response().Confirm(strs.vcrole_removed(Format.Bold(vcId.ToString()))).SendAsync(); + else + await Response().Error(strs.vcrole_not_found).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task VcRole([Leftover] IRole role = null) + { + var user = (IGuildUser)ctx.User; + + var vc = user.VoiceChannel; + + if (vc is null || vc.GuildId != user.GuildId) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + if (role is null) + { + if (_service.RemoveVcRole(ctx.Guild.Id, vc.Id)) + await Response().Confirm(strs.vcrole_removed(Format.Bold(vc.Name))).SendAsync(); + } + else + { + _service.AddVcRole(ctx.Guild.Id, role, vc.Id); + await Response().Confirm(strs.vcrole_added(Format.Bold(vc.Name), Format.Bold(role.Name))).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task VcRoleList() + { + var guild = (SocketGuild)ctx.Guild; + string text; + if (_service.VcRoles.TryGetValue(ctx.Guild.Id, out var roles)) + { + if (!roles.Any()) + text = GetText(strs.no_vcroles); + else + { + text = string.Join("\n", + roles.Select(x + => $"{Format.Bold(guild.GetVoiceChannel(x.Key)?.Name ?? x.Key.ToString())} => {x.Value}")); + } + } + else + text = GetText(strs.no_vcroles); + + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.vc_role_list)) + .WithDescription(text)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs b/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs new file mode 100644 index 0000000..c2dc60b --- /dev/null +++ b/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs @@ -0,0 +1,208 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public class VcRoleService : IEService +{ + public ConcurrentDictionary> VcRoles { get; } + public ConcurrentDictionary> ToAssign { get; } + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public VcRoleService(DiscordSocketClient client, IBot bot, DbService db) + { + _db = db; + _client = client; + + _client.UserVoiceStateUpdated += ClientOnUserVoiceStateUpdated; + VcRoles = new(); + ToAssign = new(); + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + uow.Set() + .AsQueryable() + .Include(x => x.VcRoleInfos) + .Where(x => guildIds.Contains(x.GuildId)) + .AsEnumerable() + .Select(InitializeVcRole) + .WhenAll(); + } + + Task.Run(async () => + { + while (true) + { + Task Selector(System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)> queue) + { + return Task.Run(async () => + { + while (queue.TryDequeue(out var item)) + { + var (add, user, role) = item; + + try + { + if (add) + { + if (!user.RoleIds.Contains(role.Id)) + await user.AddRoleAsync(role); + } + else + { + if (user.RoleIds.Contains(role.Id)) + await user.RemoveRoleAsync(role); + } + } + catch + { + } + + await Task.Delay(250); + } + }); + } + + await ToAssign.Values.Select(Selector).Append(Task.Delay(1000)).WhenAll(); + } + }); + + _client.LeftGuild += _client_LeftGuild; + bot.JoinedGuild += Bot_JoinedGuild; + } + + private Task Bot_JoinedGuild(GuildConfig arg) + { + // includeall no longer loads vcrole + // need to load new guildconfig with vc role included + using (var uow = _db.GetDbContext()) + { + var configWithVcRole = uow.GuildConfigsForId(arg.GuildId, set => set.Include(x => x.VcRoleInfos)); + _ = InitializeVcRole(configWithVcRole); + } + + return Task.CompletedTask; + } + + private Task _client_LeftGuild(SocketGuild arg) + { + VcRoles.TryRemove(arg.Id, out _); + ToAssign.TryRemove(arg.Id, out _); + return Task.CompletedTask; + } + + private async Task InitializeVcRole(GuildConfig gconf) + { + var g = _client.GetGuild(gconf.GuildId); + if (g is null) + return; + + var infos = new ConcurrentDictionary(); + var missingRoles = new List(); + VcRoles.AddOrUpdate(gconf.GuildId, infos, delegate { return infos; }); + foreach (var ri in gconf.VcRoleInfos) + { + var role = g.GetRole(ri.RoleId); + if (role is null) + { + missingRoles.Add(ri); + continue; + } + + infos.TryAdd(ri.VoiceChannelId, role); + } + + if (missingRoles.Any()) + { + await using var uow = _db.GetDbContext(); + uow.RemoveRange(missingRoles); + await uow.SaveChangesAsync(); + + Log.Warning("Removed {MissingRoleCount} missing roles from {ServiceName}", + missingRoles.Count, + nameof(VcRoleService)); + } + } + + public void AddVcRole(ulong guildId, IRole role, ulong vcId) + { + ArgumentNullException.ThrowIfNull(role); + + var guildVcRoles = VcRoles.GetOrAdd(guildId, new ConcurrentDictionary()); + + guildVcRoles.AddOrUpdate(vcId, role, (_, _) => role); + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos)); + var toDelete = conf.VcRoleInfos.FirstOrDefault(x => x.VoiceChannelId == vcId); // remove old one + if (toDelete is not null) + uow.Remove(toDelete); + conf.VcRoleInfos.Add(new() + { + VoiceChannelId = vcId, + RoleId = role.Id + }); // add new one + uow.SaveChanges(); + } + + public bool RemoveVcRole(ulong guildId, ulong vcId) + { + if (!VcRoles.TryGetValue(guildId, out var guildVcRoles)) + return false; + + if (!guildVcRoles.TryRemove(vcId, out _)) + return false; + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos)); + var toRemove = conf.VcRoleInfos.Where(x => x.VoiceChannelId == vcId).ToList(); + uow.RemoveRange(toRemove); + uow.SaveChanges(); + + return true; + } + + private Task ClientOnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState) + { + if (usr is not SocketGuildUser gusr) + return Task.CompletedTask; + + var oldVc = oldState.VoiceChannel; + var newVc = newState.VoiceChannel; + _ = Task.Run(() => + { + try + { + if (oldVc != newVc) + { + ulong guildId; + guildId = newVc?.Guild.Id ?? oldVc.Guild.Id; + + if (VcRoles.TryGetValue(guildId, out var guildVcRoles)) + { + //remove old + if (oldVc is not null && guildVcRoles.TryGetValue(oldVc.Id, out var role)) + Assign(false, gusr, role); + //add new + if (newVc is not null && guildVcRoles.TryGetValue(newVc.Id, out role)) + Assign(true, gusr, role); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error in VcRoleService VoiceStateUpdate"); + } + }); + return Task.CompletedTask; + } + + private void Assign(bool v, SocketGuildUser gusr, IRole role) + { + var queue = ToAssign.GetOrAdd(gusr.Guild.Id, new System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)>()); + queue.Enqueue((v, gusr, role)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs b/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs new file mode 100644 index 0000000..bd3eaab --- /dev/null +++ b/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Administration._common.results; + +public enum SetServerBannerResult +{ + Success, + InvalidFileType, + Toolarge, + InvalidURL +} diff --git a/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs b/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs new file mode 100644 index 0000000..f0d0b1a --- /dev/null +++ b/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Administration._common.results; + +public enum SetServerIconResult +{ + Success, + InvalidFileType, + InvalidURL +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs b/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs new file mode 100644 index 0000000..72606a7 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs @@ -0,0 +1,91 @@ +#nullable disable +using EllieBot.Db.Models; +using System.Runtime.CompilerServices; + +namespace EllieBot.Modules.EllieExpressions; + +public static class EllieExpressionExtensions +{ + private static string ResolveTriggerString(this string str, DiscordSocketClient client) + => str.Replace("%bot.mention%", client.CurrentUser.Mention, StringComparison.Ordinal); + + public static async Task Send( + this EllieExpression cr, + IUserMessage ctx, + IReplacementService repSvc, + DiscordSocketClient client, + IMessageSenderService sender) + { + var channel = cr.DmResponse ? await ctx.Author.CreateDMChannelAsync() : ctx.Channel; + + var trigger = cr.Trigger.ResolveTriggerString(client); + var substringIndex = trigger.Length; + if (cr.ContainsAnywhere) + { + var pos = ctx.Content.AsSpan().GetWordPosition(trigger); + if (pos == WordPosition.Start) + substringIndex += 1; + else if (pos == WordPosition.End) + substringIndex = ctx.Content.Length; + else if (pos == WordPosition.Middle) + substringIndex += ctx.Content.IndexOf(trigger, StringComparison.InvariantCulture); + } + + var canMentionEveryone = (ctx.Author as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + + var repCtx = new ReplacementContext(client: client, + guild: (ctx.Channel as ITextChannel)?.Guild as SocketGuild, + channel: ctx.Channel, + users: ctx.Author + ) + .WithOverride("%target%", + () => canMentionEveryone + ? ctx.Content[substringIndex..].Trim() + : ctx.Content[substringIndex..].Trim().SanitizeMentions(true)); + + var text = SmartText.CreateFrom(cr.Response); + text = await repSvc.ReplaceAsync(text, repCtx); + + return await sender.Response(channel).Text(text).Sanitize(false).SendAsync(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static WordPosition GetWordPosition(this ReadOnlySpan str, in ReadOnlySpan word) + { + var wordIndex = str.IndexOf(word, StringComparison.OrdinalIgnoreCase); + if (wordIndex == -1) + return WordPosition.None; + + if (wordIndex == 0) + { + if (word.Length < str.Length && str.IsValidWordDivider(word.Length)) + return WordPosition.Start; + } + else if (wordIndex + word.Length == str.Length) + { + if (str.IsValidWordDivider(wordIndex - 1)) + return WordPosition.End; + } + else if (str.IsValidWordDivider(wordIndex - 1) && str.IsValidWordDivider(wordIndex + word.Length)) + return WordPosition.Middle; + + return WordPosition.None; + } + + private static bool IsValidWordDivider(this in ReadOnlySpan str, int index) + { + var ch = str[index]; + if (ch is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '1' and <= '9') + return false; + + return true; + } +} + +public enum WordPosition +{ + None, + Start, + Middle, + End +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/EllieExpressions.cs b/src/EllieBot/Modules/Expressions/EllieExpressions.cs new file mode 100644 index 0000000..5def4f3 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressions.cs @@ -0,0 +1,447 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.EllieExpressions; + +[Name("Expressions")] +public partial class EllieExpressions : EllieModule +{ + public enum All + { + All + } + + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _clientFactory; + + public EllieExpressions(IBotCredentials creds, IHttpClientFactory clientFactory) + { + _creds = creds; + _clientFactory = clientFactory; + } + + private bool AdminInGuildOrOwnerInDm() + => (ctx.Guild is null && _creds.IsOwner(ctx.User)) + || (ctx.Guild is not null && ((IGuildUser)ctx.User).GuildPermissions.Administrator); + + private async Task ExprAddInternalAsync(string key, string message) + { + if (string.IsNullOrWhiteSpace(message) || string.IsNullOrWhiteSpace(key)) + { + return; + } + + var ex = await _service.AddAsync(ctx.Guild?.Id, key, message); + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_new)) + .WithDescription($"#{new kwum(ex.Id)}") + .AddField(GetText(strs.trigger), key) + .AddField(GetText(strs.response), + message.Length > 1024 ? GetText(strs.redacted_too_long) : message)) + .SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprToggleGlobal() + { + var result = await _service.ToggleGlobalExpressionsAsync(ctx.Guild.Id); + if (result) + await Response().Confirm(strs.expr_global_disabled).SendAsync(); + else + await Response().Confirm(strs.expr_global_enabled).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprAddServer(string key, [Leftover] string message) + { + if (string.IsNullOrWhiteSpace(message) || string.IsNullOrWhiteSpace(key)) + { + return; + } + + await ExprAddInternalAsync(key, message); + } + + + [Cmd] + public async Task ExprAdd(string trigger, [Leftover] string response) + { + if (string.IsNullOrWhiteSpace(response) || string.IsNullOrWhiteSpace(trigger)) + { + return; + } + + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + await ExprAddInternalAsync(trigger, response); + } + + [Cmd] + public async Task ExprEdit(kwum id, [Leftover] string message) + { + var channel = ctx.Channel as ITextChannel; + if (string.IsNullOrWhiteSpace(message) || id < 0) + { + return; + } + + if (!IsValidExprEditor()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var ex = await _service.EditAsync(ctx.Guild?.Id, id, message); + if (ex is not null) + { + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_edited)) + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), ex.Trigger) + .AddField(GetText(strs.response), + message.Length > 1024 ? GetText(strs.redacted_too_long) : message)) + .SendAsync(); + } + else + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + } + } + + private bool IsValidExprEditor() + => (ctx.Guild is not null && ((IGuildUser)ctx.User).GuildPermissions.Administrator) + || (ctx.Guild is null && _creds.IsOwner(ctx.User)); + + [Cmd] + [Priority(1)] + public async Task ExprList(int page = 1) + { + if (--page < 0 || page > 999) + { + return; + } + + var allExpressions = _service.GetExpressionsFor(ctx.Guild?.Id) + .OrderBy(x => x.Trigger) + .ToArray(); + + if (!allExpressions.Any()) + { + await Response().Error(strs.expr_no_found).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(allExpressions) + .PageSize(20) + .CurrentPage(page) + .Page((exprs, _) => + { + var desc = exprs + .Select(ex => $"{(ex.ContainsAnywhere ? "🗯" : "◾")}" + + $"{(ex.DmResponse ? "✉" : "◾")}" + + $"{(ex.AutoDeleteTrigger ? "❌" : "◾")}" + + $"`{(kwum)ex.Id}` {ex.Trigger}" + + (string.IsNullOrWhiteSpace(ex.Reactions) + ? string.Empty + : " // " + string.Join(" ", ex.GetReactions()))) + .Join('\n'); + + return _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.expressions)).WithDescription(desc); + }) + .SendAsync(); + } + + [Cmd] + public async Task ExprShow(kwum id) + { + var found = _service.GetExpression(ctx.Guild?.Id, id); + + if (found is null) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + var inter = CreateEditInteraction(id, found); + + await Response() + .Interaction(IsValidExprEditor() ? inter : null) + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), found.Trigger.TrimTo(1024)) + .AddField(GetText(strs.response), + found.Response.TrimTo(1000).Replace("](", "]\\("))) + .SendAsync(); + } + + private EllieInteractionBase CreateEditInteraction(kwum id, EllieExpression found) + { + var modal = new ModalBuilder() + .WithCustomId("expr:edit_modal") + .WithTitle($"Edit expression {id}") + .AddTextInput(new TextInputBuilder() + .WithLabel(GetText(strs.response)) + .WithValue(found.Response) + .WithMinLength(1) + .WithCustomId("expr:edit_modal:response") + .WithStyle(TextInputStyle.Paragraph)); + + var inter = _inter.Create(ctx.User.Id, + new ButtonBuilder() + .WithEmote(Emoji.Parse("📝")) + .WithLabel("Edit") + .WithStyle(ButtonStyle.Primary) + .WithCustomId("test"), + modal, + async (sm) => + { + var msg = sm.Data.Components.FirstOrDefault()?.Value; + + await ExprEdit(id, msg); + } + ); + return inter; + } + + public async Task ExprDeleteInternalAsync(kwum id) + { + var ex = await _service.DeleteAsync(ctx.Guild?.Id, id); + + if (ex is not null) + { + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_deleted)) + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), ex.Trigger.TrimTo(1024)) + .AddField(GetText(strs.response), ex.Response.TrimTo(1024))) + .SendAsync(); + } + else + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + } + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + [RequireContext(ContextType.Guild)] + public async Task ExprDeleteServer(kwum id) + => await ExprDeleteInternalAsync(id); + + [Cmd] + public async Task ExprDelete(kwum id) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + await ExprDeleteInternalAsync(id); + } + + [Cmd] + public async Task ExprReact(kwum id, params string[] emojiStrs) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var ex = _service.GetExpression(ctx.Guild?.Id, id); + if (ex is null) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + if (emojiStrs.Length == 0) + { + await _service.ResetExprReactions(ctx.Guild?.Id, id); + await Response().Confirm(strs.expr_reset(Format.Bold(id.ToString()))).SendAsync(); + return; + } + + var succ = new List(); + foreach (var emojiStr in emojiStrs) + { + var emote = emojiStr.ToIEmote(); + + // i should try adding these emojis right away to the message, to make sure the bot can react with these emojis. If it fails, skip that emoji + try + { + await ctx.Message.AddReactionAsync(emote); + await Task.Delay(100); + succ.Add(emojiStr); + + if (succ.Count >= 3) + { + break; + } + } + catch { } + } + + if (succ.Count == 0) + { + await Response().Error(strs.invalid_emojis).SendAsync(); + return; + } + + await _service.SetExprReactions(ctx.Guild?.Id, id, succ); + + + await Response() + .Confirm(strs.expr_set(Format.Bold(id.ToString()), + succ.Select(static x => x.ToString()).Join(", "))) + .SendAsync(); + } + + [Cmd] + public Task ExprCa(kwum id) + => InternalExprEdit(id, ExprField.ContainsAnywhere); + + [Cmd] + public Task ExprDm(kwum id) + => InternalExprEdit(id, ExprField.DmResponse); + + [Cmd] + public Task ExprAd(kwum id) + => InternalExprEdit(id, ExprField.AutoDelete); + + [Cmd] + public Task ExprAt(kwum id) + => InternalExprEdit(id, ExprField.AllowTarget); + + [Cmd] + [OwnerOnly] + public async Task ExprsReload() + { + await _service.TriggerReloadExpressions(); + + await ctx.OkAsync(); + } + + private async Task InternalExprEdit(kwum id, ExprField option) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var (success, newVal) = await _service.ToggleExprOptionAsync(ctx.Guild?.Id, id, option); + if (!success) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + if (newVal) + { + await Response() + .Confirm(strs.option_enabled(Format.Code(option.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.option_disabled(Format.Code(option.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprClear() + { + if (await PromptUserConfirmAsync(_sender.CreateEmbed() + .WithTitle("Expression clear") + .WithDescription("This will delete all expressions on this server."))) + { + var count = _service.DeleteAllExpressions(ctx.Guild.Id); + await Response().Confirm(strs.exprs_cleared(count)).SendAsync(); + } + } + + [Cmd] + public async Task ExprsExport() + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var serialized = _service.ExportExpressions(ctx.Guild?.Id); + await using var stream = await serialized.ToStream(); + await ctx.Channel.SendFileAsync(stream, "exprs-export.yml"); + } + + [Cmd] +#if GLOBAL_ELLIE + [OwnerOnly] +#endif + public async Task ExprsImport([Leftover] string input = null) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + input = input?.Trim(); + + _ = ctx.Channel.TriggerTypingAsync(); + + if (input is null) + { + var attachment = ctx.Message.Attachments.FirstOrDefault(); + if (attachment is null) + { + await Response().Error(strs.expr_import_no_input).SendAsync(); + return; + } + + using var client = _clientFactory.CreateClient(); + input = await client.GetStringAsync(attachment.Url); + + if (string.IsNullOrWhiteSpace(input)) + { + await Response().Error(strs.expr_import_no_input).SendAsync(); + return; + } + } + + var succ = await _service.ImportExpressionsAsync(ctx.Guild?.Id, input); + if (!succ) + { + await Response().Error(strs.expr_import_invalid_data).SendAsync(); + return; + } + + await ctx.OkAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs b/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs new file mode 100644 index 0000000..2f0b740 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs @@ -0,0 +1,801 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Common.Yml; +using EllieBot.Db; +using EllieBot.Db.Models; +using System.Runtime.CompilerServices; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Modules.Permissions.Services; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace EllieBot.Modules.EllieExpressions; + +public sealed class EllieExpressionsService : IExecOnMessage, IReadyExecutor +{ + private const string MENTION_PH = "%bot.mention%"; + + private const string PREPEND_EXPORT = + """ + # Keys are triggers, Each key has a LIST of expressions in the following format: + # - res: Response string + # id: Alphanumeric id used for commands related to the expression. (Note, when using .exprsimport, a new id will be generated.) + # react: + # - + # at: Whether expression allows targets (see .h .exprat) + # ca: Whether expression expects trigger anywhere (see .h .exprca) + # dm: Whether expression DMs the response (see .h .exprdm) + # ad: Whether expression automatically deletes triggering message (see .h .exprad) + + + """; + + private static readonly ISerializer _exportSerializer = new SerializerBuilder() + .WithEventEmitter(args + => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .ConfigureDefaultValuesHandling(DefaultValuesHandling + .OmitDefaults) + .DisableAliases() + .Build(); + + public int Priority + => 0; + + private readonly object _gexprWriteLock = new(); + + private readonly TypedKey _gexprAddedKey = new("gexpr.added"); + private readonly TypedKey _gexprDeletedkey = new("gexpr.deleted"); + private readonly TypedKey _gexprEditedKey = new("gexpr.edited"); + private readonly TypedKey _exprsReloadedKey = new("exprs.reloaded"); + + // it is perfectly fine to have global expressions as an array + // 1. expressions are almost never added (compared to how many times they are being looped through) + // 2. only need write locks for this as we'll rebuild+replace the array on every edit + // 3. there's never many of them (at most a thousand, usually < 100) + private EllieExpression[] globalExpressions = Array.Empty(); + private ConcurrentDictionary newguildExpressions = new(); + + private readonly DbService _db; + + private readonly DiscordSocketClient _client; + + // private readonly PermissionService _perms; + // private readonly GlobalPermissionService _gperm; + // private readonly CmdCdService _cmdCds; + private readonly IPermissionChecker _permChecker; + private readonly ICommandHandler _cmd; + private readonly IBotStrings _strings; + private readonly IBot _bot; + private readonly IPubSub _pubSub; + private readonly IMessageSenderService _sender; + private readonly IReplacementService _repSvc; + private readonly Random _rng; + + private bool ready; + private ConcurrentHashSet _disabledGlobalExpressionGuilds; + private readonly PermissionService _pc; + + public EllieExpressionsService( + DbService db, + IBotStrings strings, + IBot bot, + DiscordSocketClient client, + ICommandHandler cmd, + IPubSub pubSub, + IMessageSenderService sender, + IReplacementService repSvc, + IPermissionChecker permChecker, + PermissionService pc) + { + _db = db; + _client = client; + _cmd = cmd; + _strings = strings; + _bot = bot; + _pubSub = pubSub; + _sender = sender; + _repSvc = repSvc; + _permChecker = permChecker; + _pc = pc; + _rng = new EllieRandom(); + + _pubSub.Sub(_exprsReloadedKey, OnExprsShouldReload); + pubSub.Sub(_gexprAddedKey, OnGexprAdded); + pubSub.Sub(_gexprDeletedkey, OnGexprDeleted); + pubSub.Sub(_gexprEditedKey, OnGexprEdited); + + bot.JoinedGuild += OnJoinedGuild; + _client.LeftGuild += OnLeftGuild; + } + + private async Task ReloadInternal(IReadOnlyList allGuildIds) + { + await using var uow = _db.GetDbContext(); + var guildItems = await uow.Set() + .AsNoTracking() + .Where(x => allGuildIds.Contains(x.GuildId.Value)) + .ToListAsync(); + + newguildExpressions = guildItems.GroupBy(k => k.GuildId!.Value) + .ToDictionary(g => g.Key, + g => g.Select(x => + { + x.Trigger = x.Trigger.Replace(MENTION_PH, + _client.CurrentUser.Mention); + return x; + }) + .ToArray()) + .ToConcurrent(); + + _disabledGlobalExpressionGuilds = new(await uow.Set() + .Where(x => x.DisableGlobalExpressions) + .Select(x => x.GuildId) + .ToListAsyncLinqToDB()); + + lock (_gexprWriteLock) + { + var globalItems = uow.Set() + .AsNoTracking() + .Where(x => x.GuildId == null || x.GuildId == 0) + .Where(x => x.Trigger != null) + .AsEnumerable() + .Select(x => + { + x.Trigger = x.Trigger.Replace(MENTION_PH, _client.CurrentUser.Mention); + return x; + }) + .ToArray(); + + globalExpressions = globalItems; + } + + ready = true; + } + + private EllieExpression TryGetExpression(IUserMessage umsg) + { + if (!ready) + return null; + + if (umsg.Channel is not SocketTextChannel channel) + return null; + + var content = umsg.Content.Trim().ToLowerInvariant(); + + if (newguildExpressions.TryGetValue(channel.Guild.Id, out var expressions) && expressions.Length > 0) + { + var expr = MatchExpressions(content, expressions); + if (expr is not null) + return expr; + } + + if (_disabledGlobalExpressionGuilds.Contains(channel.Guild.Id)) + return null; + + var localGrs = globalExpressions; + + return MatchExpressions(content, localGrs); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private EllieExpression MatchExpressions(in ReadOnlySpan content, EllieExpression[] exprs) + { + var result = new List(1); + for (var i = 0; i < exprs.Length; i++) + { + var expr = exprs[i]; + var trigger = expr.Trigger; + if (content.Length > trigger.Length) + { + // if input is greater than the trigger, it can only work if: + // it has CA enabled + if (expr.ContainsAnywhere) + { + // if ca is enabled, we have to check if it is a word within the content + var wp = content.GetWordPosition(trigger); + + // if it is, then that's valid + if (wp != WordPosition.None) + result.Add(expr); + + // if it's not, then it cant' work under any circumstance, + // because content is greater than the trigger length + // so it can't be equal, and it's not contained as a word + continue; + } + + // if CA is disabled, and expr has AllowTarget, then the + // content has to start with the trigger followed by a space + if (expr.AllowTarget + && content.StartsWith(trigger, StringComparison.OrdinalIgnoreCase) + && content[trigger.Length] == ' ') + result.Add(expr); + } + else if (content.Length < expr.Trigger.Length) + { + // if input length is less than trigger length, it means + // that the reaction can never be triggered + } + else + { + // if input length is the same as trigger length + // reaction can only trigger if the strings are equal + if (content.SequenceEqual(expr.Trigger)) + result.Add(expr); + } + } + + if (result.Count == 0) + return null; + + var cancelled = result.FirstOrDefault(x => x.Response == "-"); + if (cancelled is not null) + return cancelled; + + return result[_rng.Next(0, result.Count)]; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + // maybe this message is an expression + var expr = TryGetExpression(msg); + + if (expr is null || expr.Response == "-") + return false; + + try + { + if (guild is SocketGuild sg) + { + var result = await _permChecker.CheckPermsAsync( + guild, + msg.Channel, + msg.Author, + "ACTUALEXPRESSIONS", + expr.Trigger + ); + + if (!result.IsAllowed) + { + var cache = _pc.GetCacheFor(guild.Id); + if (cache.Verbose) + { + if (result.TryPickT3(out var disallowed, out _)) + { + var permissionMessage = _strings.GetText(strs.perm_prevent(disallowed.PermIndex + 1, + Format.Bold(disallowed.PermText)), + sg.Id); + + try + { + await _sender.Response(msg.Channel) + .Error(permissionMessage) + .SendAsync(); + } + catch + { + } + + Log.Information("{PermissionMessage}", permissionMessage); + } + } + + return true; + } + } + + var sentMsg = await expr.Send(msg, _repSvc, _client, _sender); + + var reactions = expr.GetReactions(); + foreach (var reaction in reactions) + { + try + { + await sentMsg.AddReactionAsync(reaction.ToIEmote()); + } + catch + { + Log.Warning("Unable to add reactions to message {Message} in server {GuildId}", + sentMsg.Id, + expr.GuildId); + break; + } + + await Task.Delay(1000); + } + + if (expr.AutoDeleteTrigger) + { + try + { + await msg.DeleteAsync(); + } + catch + { + } + } + + Log.Information("s: {GuildId} c: {ChannelId} u: {UserId} | {UserName} executed expression {Expr}", + guild.Id, + msg.Channel.Id, + msg.Author.Id, + msg.Author.ToString(), + expr.Trigger); + + return true; + } + catch (Exception ex) + { + Log.Warning(ex, "Error in Expression RunBehavior: {ErrorMessage}", ex.Message); + } + + return false; + } + + public async Task ResetExprReactions(ulong? maybeGuildId, int id) + { + EllieExpression expr; + await using var uow = _db.GetDbContext(); + expr = uow.Set().GetById(id); + if (expr is null) + return; + + expr.Reactions = string.Empty; + + await uow.SaveChangesAsync(); + } + + private Task UpdateInternalAsync(ulong? maybeGuildId, EllieExpression expr) + { + if (maybeGuildId is { } guildId) + UpdateInternal(guildId, expr); + else + return _pubSub.Pub(_gexprEditedKey, expr); + + return Task.CompletedTask; + } + + private void UpdateInternal(ulong? maybeGuildId, EllieExpression expr) + { + if (maybeGuildId is { } guildId) + { + newguildExpressions.AddOrUpdate(guildId, + [expr], + (_, old) => + { + var newArray = old.ToArray(); + for (var i = 0; i < newArray.Length; i++) + { + if (newArray[i].Id == expr.Id) + newArray[i] = expr; + } + + return newArray; + }); + } + else + { + lock (_gexprWriteLock) + { + var exprs = globalExpressions; + for (var i = 0; i < exprs.Length; i++) + { + if (exprs[i].Id == expr.Id) + exprs[i] = expr; + } + } + } + } + + private Task AddInternalAsync(ulong? maybeGuildId, EllieExpression expr) + { + // only do this for perf purposes + expr.Trigger = expr.Trigger.Replace(MENTION_PH, _client.CurrentUser.Mention); + + if (maybeGuildId is { } guildId) + newguildExpressions.AddOrUpdate(guildId, [expr], (_, old) => old.With(expr)); + else + return _pubSub.Pub(_gexprAddedKey, expr); + + return Task.CompletedTask; + } + + private Task DeleteInternalAsync(ulong? maybeGuildId, int id) + { + if (maybeGuildId is { } guildId) + { + newguildExpressions.AddOrUpdate(guildId, + Array.Empty(), + (key, old) => DeleteInternal(old, id, out _)); + + return Task.CompletedTask; + } + + lock (_gexprWriteLock) + { + var expr = Array.Find(globalExpressions, item => item.Id == id); + if (expr is not null) + return _pubSub.Pub(_gexprDeletedkey, expr.Id); + } + + return Task.CompletedTask; + } + + private EllieExpression[] DeleteInternal( + IReadOnlyList exprs, + int id, + out EllieExpression deleted) + { + deleted = null; + if (exprs is null || exprs.Count == 0) + return exprs as EllieExpression[] ?? exprs?.ToArray(); + + var newExprs = new EllieExpression[exprs.Count - 1]; + for (int i = 0, k = 0; i < exprs.Count; i++, k++) + { + if (exprs[i].Id == id) + { + deleted = exprs[i]; + k--; + continue; + } + + newExprs[k] = exprs[i]; + } + + return newExprs; + } + + public async Task SetExprReactions(ulong? guildId, int id, IEnumerable emojis) + { + EllieExpression expr; + await using (var uow = _db.GetDbContext()) + { + expr = uow.Set().GetById(id); + if (expr is null) + return; + + expr.Reactions = string.Join("@@@", emojis); + + await uow.SaveChangesAsync(); + } + + await UpdateInternalAsync(guildId, expr); + } + + public async Task<(bool Sucess, bool NewValue)> ToggleExprOptionAsync(ulong? guildId, int id, ExprField field) + { + var newVal = false; + EllieExpression expr; + await using (var uow = _db.GetDbContext()) + { + expr = uow.Set().GetById(id); + + if (expr is null || expr.GuildId != guildId) + return (false, false); + if (field == ExprField.AutoDelete) + newVal = expr.AutoDeleteTrigger = !expr.AutoDeleteTrigger; + else if (field == ExprField.ContainsAnywhere) + newVal = expr.ContainsAnywhere = !expr.ContainsAnywhere; + else if (field == ExprField.DmResponse) + newVal = expr.DmResponse = !expr.DmResponse; + else if (field == ExprField.AllowTarget) + newVal = expr.AllowTarget = !expr.AllowTarget; + + await uow.SaveChangesAsync(); + } + + await UpdateInternalAsync(guildId, expr); + + return (true, newVal); + } + + public EllieExpression GetExpression(ulong? guildId, int id) + { + using var uow = _db.GetDbContext(); + var expr = uow.Set().GetById(id); + if (expr is null || expr.GuildId != guildId) + return null; + + return expr; + } + + public int DeleteAllExpressions(ulong guildId) + { + using var uow = _db.GetDbContext(); + var count = uow.Set().ClearFromGuild(guildId); + uow.SaveChanges(); + + newguildExpressions.TryRemove(guildId, out _); + + return count; + } + + public bool ExpressionExists(ulong? guildId, string input) + { + input = input.ToLowerInvariant(); + + var gexprs = globalExpressions; + foreach (var t in gexprs) + { + if (t.Trigger == input) + return true; + } + + if (guildId is ulong gid && newguildExpressions.TryGetValue(gid, out var guildExprs)) + { + foreach (var t in guildExprs) + { + if (t.Trigger == input) + return true; + } + } + + return false; + } + + public string ExportExpressions(ulong? guildId) + { + var exprs = GetExpressionsFor(guildId); + + var exprsDict = exprs.GroupBy(x => x.Trigger).ToDictionary(x => x.Key, x => x.Select(ExportedExpr.FromModel)); + + return PREPEND_EXPORT + _exportSerializer.Serialize(exprsDict).UnescapeUnicodeCodePoints(); + } + + public async Task ImportExpressionsAsync(ulong? guildId, string input) + { + Dictionary> data; + try + { + data = Yaml.Deserializer.Deserialize>>(input); + if (data.Sum(x => x.Value.Count) == 0) + return false; + } + catch + { + return false; + } + + await using var uow = _db.GetDbContext(); + foreach (var entry in data) + { + var trigger = entry.Key; + await uow.Set() + .AddRangeAsync(entry.Value + .Where(expr => !string.IsNullOrWhiteSpace(expr.Res)) + .Select(expr => new EllieExpression + { + GuildId = guildId, + Response = expr.Res, + Reactions = expr.React?.Join("@@@"), + Trigger = trigger, + AllowTarget = expr.At, + ContainsAnywhere = expr.Ca, + DmResponse = expr.Dm, + AutoDeleteTrigger = expr.Ad + })); + } + + await uow.SaveChangesAsync(); + await TriggerReloadExpressions(); + return true; + } + + #region Event Handlers + + public async Task OnReadyAsync() + => await OnExprsShouldReload(true); + + private ValueTask OnExprsShouldReload(bool _) + => new(ReloadInternal(_bot.GetCurrentGuildIds())); + + private ValueTask OnGexprAdded(EllieExpression c) + { + lock (_gexprWriteLock) + { + var newGlobalReactions = new EllieExpression[globalExpressions.Length + 1]; + Array.Copy(globalExpressions, newGlobalReactions, globalExpressions.Length); + newGlobalReactions[globalExpressions.Length] = c; + globalExpressions = newGlobalReactions; + } + + return default; + } + + private ValueTask OnGexprEdited(EllieExpression c) + { + lock (_gexprWriteLock) + { + for (var i = 0; i < globalExpressions.Length; i++) + { + if (globalExpressions[i].Id == c.Id) + { + globalExpressions[i] = c; + return default; + } + } + + // if edited expr is not found?! + // add it + OnGexprAdded(c); + } + + return default; + } + + private ValueTask OnGexprDeleted(int id) + { + lock (_gexprWriteLock) + { + var newGlobalReactions = DeleteInternal(globalExpressions, id, out _); + globalExpressions = newGlobalReactions; + } + + return default; + } + + public Task TriggerReloadExpressions() + => _pubSub.Pub(_exprsReloadedKey, true); + + #endregion + + #region Client Event Handlers + + private Task OnLeftGuild(SocketGuild arg) + { + newguildExpressions.TryRemove(arg.Id, out _); + + return Task.CompletedTask; + } + + private async Task OnJoinedGuild(GuildConfig gc) + { + await using var uow = _db.GetDbContext(); + var exprs = await uow.Set().AsNoTracking().Where(x => x.GuildId == gc.GuildId).ToArrayAsync(); + + newguildExpressions[gc.GuildId] = exprs; + } + + #endregion + + #region Basic Operations + + public async Task AddAsync( + ulong? guildId, + string key, + string message, + bool ca = false, + bool ad = false, + bool dm = false) + { + key = key.ToLowerInvariant(); + var expr = new EllieExpression + { + GuildId = guildId, + Trigger = key, + Response = message, + ContainsAnywhere = ca, + AutoDeleteTrigger = ad, + DmResponse = dm + }; + + if (expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = true; + + await using (var uow = _db.GetDbContext()) + { + uow.Set().Add(expr); + await uow.SaveChangesAsync(); + } + + await AddInternalAsync(guildId, expr); + + return expr; + } + + public async Task EditAsync( + ulong? guildId, + int id, + string message, + bool? ca = null, + bool? ad = null, + bool? dm = null) + { + await using var uow = _db.GetDbContext(); + var expr = uow.Set().GetById(id); + + if (expr is null || expr.GuildId != guildId) + return null; + + // disable allowtarget if message had target, but it was removed from it + if (!message.Contains("%target%", StringComparison.OrdinalIgnoreCase) + && expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = false; + + expr.Response = message; + + // enable allow target if message is edited to contain target + if (expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = true; + + expr.ContainsAnywhere = ca ?? expr.ContainsAnywhere; + expr.AutoDeleteTrigger = ad ?? expr.AutoDeleteTrigger; + expr.DmResponse = dm ?? expr.DmResponse; + + await uow.SaveChangesAsync(); + await UpdateInternalAsync(guildId, expr); + + return expr; + } + + + public async Task DeleteAsync(ulong? guildId, int id) + { + await using var uow = _db.GetDbContext(); + var toDelete = uow.Set().GetById(id); + + if (toDelete is null) + return null; + + if ((toDelete.IsGlobal() && guildId is null) || guildId == toDelete.GuildId) + { + uow.Set().Remove(toDelete); + await uow.SaveChangesAsync(); + await DeleteInternalAsync(guildId, id); + return toDelete; + } + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public EllieExpression[] GetExpressionsFor(ulong? maybeGuildId) + { + if (maybeGuildId is { } guildId) + return newguildExpressions.TryGetValue(guildId, out var exprs) ? exprs : Array.Empty(); + + return globalExpressions; + } + + #endregion + + public async Task ToggleGlobalExpressionsAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, set => set); + var toReturn = gc.DisableGlobalExpressions = !gc.DisableGlobalExpressions; + await ctx.SaveChangesAsync(); + + if (toReturn) + _disabledGlobalExpressionGuilds.Add(guildId); + else + _disabledGlobalExpressionGuilds.TryRemove(guildId); + + return toReturn; + } + + + public async Task<(IReadOnlyCollection Exprs, int TotalCount)> FindExpressionsAsync( + ulong guildId, + string query, + int page) + { + await using var ctx = _db.GetDbContext(); + + if (newguildExpressions.TryGetValue(guildId, out var exprs)) + { + return (exprs.Where(x => x.Trigger.Contains(query)) + .Skip(page * 9) + .Take(9) + .ToArray(), exprs.Length); + } + + return ([], 0); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/ExportedExpr.cs b/src/EllieBot/Modules/Expressions/ExportedExpr.cs new file mode 100644 index 0000000..c45fbdc --- /dev/null +++ b/src/EllieBot/Modules/Expressions/ExportedExpr.cs @@ -0,0 +1,27 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.EllieExpressions; + +public class ExportedExpr +{ + public string Res { get; set; } + public string Id { get; set; } + public bool Ad { get; set; } + public bool Dm { get; set; } + public bool At { get; set; } + public bool Ca { get; set; } + public string[] React; + + public static ExportedExpr FromModel(EllieExpression cr) + => new() + { + Res = cr.Response, + Id = ((kwum)cr.Id).ToString(), + Ad = cr.AutoDeleteTrigger, + At = cr.AllowTarget, + Ca = cr.ContainsAnywhere, + Dm = cr.DmResponse, + React = string.IsNullOrWhiteSpace(cr.Reactions) ? null : cr.GetReactions() + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/ExprField.cs b/src/EllieBot/Modules/Expressions/ExprField.cs new file mode 100644 index 0000000..9b9fa2f --- /dev/null +++ b/src/EllieBot/Modules/Expressions/ExprField.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Modules.EllieExpressions; + +public enum ExprField +{ + AutoDelete, + DmResponse, + AllowTarget, + ContainsAnywhere, + Message +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs b/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs new file mode 100644 index 0000000..716735e --- /dev/null +++ b/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs @@ -0,0 +1,33 @@ +#nullable disable +using EllieBot.Modules.EllieExpressions; + +namespace EllieBot.Common.TypeReaders; + +public sealed class CommandOrExprTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + private readonly ICommandHandler _commandHandler; + private readonly EllieExpressionsService _exprs; + + public CommandOrExprTypeReader(CommandService cmds, EllieExpressionsService exprs, ICommandHandler commandHandler) + { + _cmds = cmds; + _exprs = exprs; + _commandHandler = commandHandler; + } + + public override async ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (_exprs.ExpressionExists(ctx.Guild?.Id, input)) + return TypeReaderResult.FromSuccess(new CommandOrExprInfo(input, CommandOrExprInfo.Type.Custom)); + + var cmd = await new CommandTypeReader(_commandHandler, _cmds).ReadAsync(ctx, input); + if (cmd.IsSuccess) + { + return TypeReaderResult.FromSuccess(new CommandOrExprInfo(((CommandInfo)cmd.Values.First().Value).Name, + CommandOrExprInfo.Type.Normal)); + } + + return TypeReaderResult.FromError(CommandError.ParseFailed, "No such command or expression found."); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs new file mode 100644 index 0000000..84f10d4 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs @@ -0,0 +1,153 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; +using EllieBot.Modules.Games.Common; + +namespace EllieBot.Modules.Gambling.Common.AnimalRacing; + +public sealed class AnimalRace : IDisposable +{ + public enum Phase + { + WaitingForPlayers, + Running, + Ended + } + + public event Func OnStarted = delegate { return Task.CompletedTask; }; + public event Func OnStartingFailed = delegate { return Task.CompletedTask; }; + public event Func OnStateUpdate = delegate { return Task.CompletedTask; }; + public event Func OnEnded = delegate { return Task.CompletedTask; }; + + public Phase CurrentPhase { get; private set; } = Phase.WaitingForPlayers; + + public IReadOnlyCollection Users + => _users.ToList(); + + public List FinishedUsers { get; } = new(); + public int MaxUsers { get; } + + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly HashSet _users = new(); + private readonly ICurrencyService _currency; + private readonly RaceOptions _options; + private readonly Queue _animalsQueue; + + public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable availableAnimals) + { + _currency = currency; + _options = options; + _animalsQueue = new(availableAnimals); + MaxUsers = _animalsQueue.Count; + + if (_animalsQueue.Count == 0) + CurrentPhase = Phase.Ended; + } + + public void Initialize() //lame name + => _ = Task.Run(async () => + { + await Task.Delay(_options.StartTime * 1000); + + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.WaitingForPlayers) + return; + + await Start(); + } + finally { _locker.Release(); } + }); + + public async Task JoinRace(ulong userId, string userName, long bet = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(bet); + + var user = new AnimalRacingUser(userName, userId, bet); + + await _locker.WaitAsync(); + try + { + if (_users.Count == MaxUsers) + throw new AnimalRaceFullException(); + + if (CurrentPhase != Phase.WaitingForPlayers) + throw new AlreadyStartedException(); + + if (!await _currency.RemoveAsync(userId, bet, new("animalrace", "bet"))) + throw new NotEnoughFundsException(); + + if (_users.Contains(user)) + throw new AlreadyJoinedException(); + + var animal = _animalsQueue.Dequeue(); + user.Animal = animal; + _users.Add(user); + + if (_animalsQueue.Count == 0) //start if no more spots left + await Start(); + + return user; + } + finally { _locker.Release(); } + } + + private async Task Start() + { + CurrentPhase = Phase.Running; + if (_users.Count <= 1) + { + foreach (var user in _users) + { + if (user.Bet > 0) + await _currency.AddAsync(user.UserId, user.Bet, new("animalrace", "refund")); + } + + _ = OnStartingFailed?.Invoke(this); + CurrentPhase = Phase.Ended; + return; + } + + _ = OnStarted?.Invoke(this); + _ = Task.Run(async () => + { + var rng = new EllieRandom(); + while (!_users.All(x => x.Progress >= 60)) + { + foreach (var user in _users) + { + user.Progress += rng.Next(1, 11); + if (user.Progress >= 60) + user.Progress = 60; + } + + var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x)).Shuffle(); + + FinishedUsers.AddRange(finished); + + _ = OnStateUpdate?.Invoke(this); + await Task.Delay(2500); + } + + if (FinishedUsers[0].Bet > 0) + { + await _currency.AddAsync(FinishedUsers[0].UserId, + FinishedUsers[0].Bet * (_users.Count - 1), + new("animalrace", "win")); + } + + _ = OnEnded?.Invoke(this); + }); + } + + public void Dispose() + { + CurrentPhase = Phase.Ended; + OnStarted = null; + OnEnded = null; + OnStartingFailed = null; + OnStateUpdate = null; + _locker.Dispose(); + _users.Clear(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs new file mode 100644 index 0000000..f4c99a8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs @@ -0,0 +1,9 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common.AnimalRacing; + +namespace EllieBot.Modules.Gambling.Services; + +public class AnimalRaceService : IEService +{ + public ConcurrentDictionary AnimalRaces { get; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs new file mode 100644 index 0000000..1f23cf6 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs @@ -0,0 +1,197 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.AnimalRacing; +using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Gambling; + +// wth is this, needs full rewrite +public partial class Gambling +{ + [Group] + public partial class AnimalRacingCommands : GamblingSubmodule + { + private readonly ICurrencyService _cs; + private readonly DiscordSocketClient _client; + private readonly GamesConfigService _gamesConf; + + private IUserMessage raceMessage; + + public AnimalRacingCommands( + ICurrencyService cs, + DiscordSocketClient client, + GamblingConfigService gamblingConf, + GamesConfigService gamesConf) + : base(gamblingConf) + { + _cs = cs; + _client = client; + _gamesConf = gamesConf; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public Task Race(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args); + + var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle()); + if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar)) + return Response() + .Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started)) + .SendAsync(); + + ar.Initialize(); + + var count = 0; + + Task ClientMessageReceived(SocketMessage arg) + { + _ = Task.Run(() => + { + try + { + if (arg.Channel.Id == ctx.Channel.Id) + { + if (ar.CurrentPhase == AnimalRace.Phase.Running && ++count % 9 == 0) + raceMessage = null; + } + } + catch { } + }); + return Task.CompletedTask; + } + + Task ArOnEnded(AnimalRace race) + { + _client.MessageReceived -= ClientMessageReceived; + _service.AnimalRaces.TryRemove(ctx.Guild.Id, out _); + var winner = race.FinishedUsers[0]; + if (race.FinishedUsers[0].Bet > 0) + { + return Response() + .Confirm(GetText(strs.animal_race), + GetText(strs.animal_race_won_money(Format.Bold(winner.Username), + winner.Animal.Icon, + (race.FinishedUsers[0].Bet * (race.Users.Count - 1)) + CurrencySign))) + .SendAsync(); + } + + ar.Dispose(); + return Response() + .Confirm(GetText(strs.animal_race), + GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon))) + .SendAsync(); + } + + ar.OnStartingFailed += Ar_OnStartingFailed; + ar.OnStateUpdate += Ar_OnStateUpdate; + ar.OnEnded += ArOnEnded; + ar.OnStarted += Ar_OnStarted; + _client.MessageReceived += ClientMessageReceived; + + return Response() + .Confirm(GetText(strs.animal_race), + GetText(strs.animal_race_starting(options.StartTime)), + footer: GetText(strs.animal_race_join_instr(prefix))) + .SendAsync(); + } + + private Task Ar_OnStarted(AnimalRace race) + { + if (race.Users.Count == race.MaxUsers) + return Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync(); + return Response() + .Confirm(GetText(strs.animal_race), + GetText(strs.animal_race_starting_with_x(race.Users.Count))) + .SendAsync(); + } + + private async Task Ar_OnStateUpdate(AnimalRace race) + { + var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚| +{string.Join("\n", race.Users.Select(p => +{ + var index = race.FinishedUsers.IndexOf(p); + var extra = index == -1 ? "" : $"#{index + 1} {(index == 0 ? "🏆" : "")}"; + return $"{(int)(p.Progress / 60f * 100),-2}%|{new string('‣', p.Progress) + p.Animal.Icon + extra}"; +}))} +|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|"; + + var msg = raceMessage; + + if (msg is null) + raceMessage = await Response().Confirm(text).SendAsync(); + else + { + await msg.ModifyAsync(x => x.Embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.animal_race)) + .WithDescription(text) + .WithOkColor() + .Build()); + } + } + + private Task Ar_OnStartingFailed(AnimalRace race) + { + _service.AnimalRaces.TryRemove(ctx.Guild.Id, out _); + race.Dispose(); + return Response().Error(strs.animal_race_failed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task JoinRace([OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default) + { + if (!await CheckBetOptional(amount)) + return; + + if (!_service.AnimalRaces.TryGetValue(ctx.Guild.Id, out var ar)) + { + await Response().Error(strs.race_not_exist).SendAsync(); + return; + } + + try + { + var user = await ar.JoinRace(ctx.User.Id, ctx.User.ToString(), amount); + if (amount > 0) + { + await Response() + .Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention, + user.Animal.Icon, + amount + CurrencySign))) + .SendAsync(); + } + else + await Response() + .Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon)) + .SendAsync(); + } + catch (ArgumentOutOfRangeException) + { + //ignore if user inputed an invalid amount + } + catch (AlreadyJoinedException) + { + // just ignore this + } + catch (AlreadyStartedException) + { + //ignore + } + catch (AnimalRaceFullException) + { + await Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync(); + } + catch (NotEnoughFundsException) + { + await Response().Error(GetText(strs.not_enough(CurrencySign))).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs new file mode 100644 index 0000000..814b475 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs @@ -0,0 +1,26 @@ +#nullable disable +using EllieBot.Modules.Games.Common; + +namespace EllieBot.Modules.Gambling.Common.AnimalRacing; + +public class AnimalRacingUser +{ + public long Bet { get; } + public string Username { get; } + public ulong UserId { get; } + public RaceAnimal Animal { get; set; } + public int Progress { get; set; } + + public AnimalRacingUser(string username, ulong userId, long bet) + { + Bet = bet; + Username = username; + UserId = userId; + } + + public override bool Equals(object obj) + => obj is AnimalRacingUser x ? x.UserId == UserId : false; + + public override int GetHashCode() + => UserId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs new file mode 100644 index 0000000..914b6a4 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AlreadyJoinedException : Exception +{ + public AlreadyJoinedException() + { + } + + public AlreadyJoinedException(string message) + : base(message) + { + } + + public AlreadyJoinedException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs new file mode 100644 index 0000000..e662785 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AlreadyStartedException : Exception +{ + public AlreadyStartedException() + { + } + + public AlreadyStartedException(string message) + : base(message) + { + } + + public AlreadyStartedException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs new file mode 100644 index 0000000..9a76b5b --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AnimalRaceFullException : Exception +{ + public AnimalRaceFullException() + { + } + + public AnimalRaceFullException(string message) + : base(message) + { + } + + public AnimalRaceFullException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs new file mode 100644 index 0000000..b827761 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class NotEnoughFundsException : Exception +{ + public NotEnoughFundsException() + { + } + + public NotEnoughFundsException(string message) + : base(message) + { + } + + public NotEnoughFundsException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs new file mode 100644 index 0000000..fb0f8c9 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs @@ -0,0 +1,16 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Gambling.Common.AnimalRacing; + +public class RaceOptions : IEllieCommandOptions +{ + [Option('s', "start-time", Default = 20, Required = false)] + public int StartTime { get; set; } = 20; + + public void NormalizeOptions() + { + if (StartTime is < 10 or > 120) + StartTime = 20; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Bank/BankCommands.cs b/src/EllieBot/Modules/Gambling/Bank/BankCommands.cs new file mode 100644 index 0000000..91388ef --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Bank/BankCommands.cs @@ -0,0 +1,118 @@ +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Bank; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Name("Bank")] + [Group("bank")] + public partial class BankCommands : GamblingModule + { + private readonly IBankService _bank; + private readonly DiscordSocketClient _client; + + public BankCommands(GamblingConfigService gcs, + IBankService bank, + DiscordSocketClient client) : base(gcs) + { + _bank = bank; + _client = client; + } + + [Cmd] + public async Task BankDeposit([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (amount <= 0) + return; + + if (await _bank.DepositAsync(ctx.User.Id, amount)) + { + await Response().Confirm(strs.bank_deposited(N(amount))).SendAsync(); + } + else + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + } + + [Cmd] + public async Task BankWithdraw([OverrideTypeReader(typeof(BankBalanceTypeReader))] long amount) + { + if (amount <= 0) + return; + + if (await _bank.WithdrawAsync(ctx.User.Id, amount)) + { + await Response().Confirm(strs.bank_withdrew(N(amount))).SendAsync(); + } + else + { + await Response().Error(strs.bank_withdraw_insuff(CurrencySign)).SendAsync(); + } + } + + [Cmd] + public async Task BankBalance() + { + var bal = await _bank.GetBalanceAsync(ctx.User.Id); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(GetText(strs.bank_balance(N(bal)))); + + try + { + await Response().User(ctx.User).Embed(eb).SendAsync(); + await ctx.OkAsync(); + } + catch + { + await Response().Error(strs.cant_dm).SendAsync(); + } + } + + private async Task BankTakeInternalAsync(long amount, ulong userId) + { + if (await _bank.TakeAsync(userId, amount)) + { + await ctx.OkAsync(); + return; + } + + await Response().Error(strs.take_fail(N(amount), + _client.GetUser(userId)?.ToString() + ?? userId.ToString(), + CurrencySign)).SendAsync(); + } + + private async Task BankAwardInternalAsync(long amount, ulong userId) + { + if (await _bank.AwardAsync(userId, amount)) + { + await ctx.OkAsync(); + return; + } + + } + + [Cmd] + [OwnerOnly] + [Priority(1)] + public async Task BankTake(long amount, [Leftover] IUser user) + => await BankTakeInternalAsync(amount, user.Id); + + [Cmd] + [OwnerOnly] + [Priority(0)] + public async Task BankTake(long amount, ulong userId) + => await BankTakeInternalAsync(amount, userId); + + [Cmd] + [OwnerOnly] + public async Task BankAward(long amount, [Leftover] IUser user) + => await BankAwardInternalAsync(amount, user.Id); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Bank/BankService.cs b/src/EllieBot/Modules/Gambling/Bank/BankService.cs new file mode 100644 index 0000000..0d75607 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Bank/BankService.cs @@ -0,0 +1,115 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Bank; + +public sealed class BankService : IBankService, IEService +{ + private readonly ICurrencyService _cur; + private readonly DbService _db; + + public BankService(ICurrencyService cur, DbService db) + { + _cur = cur; + _db = db; + } + + public async Task AwardAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Balance = amount + }, + (old) => new() + { + Balance = old.Balance + amount + }, + () => new() + { + UserId = userId + }); + + return true; + } + + public async Task TakeAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set() + .ToLinqToDBTable() + .Where(x => x.UserId == userId && x.Balance >= amount) + .UpdateAsync((old) => new() + { + Balance = old.Balance - amount + }); + + return rows > 0; + } + + public async Task DepositAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit"))) + return false; + + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Balance = amount + }, + (old) => new() + { + Balance = old.Balance + amount + }, + () => new() + { + UserId = userId + }); + + return true; + } + + public async Task WithdrawAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set() + .ToLinqToDBTable() + .Where(x => x.UserId == userId && x.Balance >= amount) + .UpdateAsync((old) => new() + { + Balance = old.Balance - amount + }); + + if (rows > 0) + { + await _cur.AddAsync(userId, amount, new("bank", "withdraw")); + return true; + } + + return false; + } + + public async Task GetBalanceAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + return (await ctx.Set() + .ToLinqToDBTable() + .FirstOrDefaultAsync(x => x.UserId == userId)) + ?.Balance + ?? 0; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs new file mode 100644 index 0000000..772cb4f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs @@ -0,0 +1,183 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Blackjack; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + public partial class BlackJackCommands : GamblingSubmodule + { + public enum BjAction + { + Hit = int.MinValue, + Stand, + Double + } + + private readonly ICurrencyService _cs; + private readonly DbService _db; + private IUserMessage msg; + + public BlackJackCommands(ICurrencyService cs, DbService db, GamblingConfigService gamblingConf) + : base(gamblingConf) + { + _cs = cs; + _db = db; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task BlackJack([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + return; + + var newBj = new Blackjack(_cs); + Blackjack bj; + if (newBj == (bj = _service.Games.GetOrAdd(ctx.Channel.Id, newBj))) + { + if (!await bj.Join(ctx.User, amount)) + { + _service.Games.TryRemove(ctx.Channel.Id, out _); + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + bj.StateUpdated += Bj_StateUpdated; + bj.GameEnded += Bj_GameEnded; + bj.Start(); + + await Response().NoReply().Confirm(strs.bj_created(ctx.User.ToString())).SendAsync(); + } + else + { + if (await bj.Join(ctx.User, amount)) + await Response().NoReply().Confirm(strs.bj_joined(ctx.User.ToString())).SendAsync(); + else + { + Log.Information("{User} can't join a blackjack game as it's in {BlackjackState} state already", + ctx.User, + bj.State); + } + } + + await ctx.Message.DeleteAsync(); + } + + private Task Bj_GameEnded(Blackjack arg) + { + _service.Games.TryRemove(ctx.Channel.Id, out _); + return Task.CompletedTask; + } + + private async Task Bj_StateUpdated(Blackjack bj) + { + try + { + if (msg is not null) + _ = msg.DeleteAsync(); + + var c = bj.Dealer.Cards.Select(x => x.GetEmojiString()) + .ToList(); + var dealerIcon = "❔ "; + if (bj.State == Blackjack.GameState.Ended) + { + if (bj.Dealer.GetHandValue() == 21) + dealerIcon = "💰 "; + else if (bj.Dealer.GetHandValue() > 21) + dealerIcon = "💥 "; + else + dealerIcon = "🏁 "; + } + + var cStr = string.Concat(c.Select(x => x[..^1] + " ")); + cStr += "\n" + string.Concat(c.Select(x => x.Last() + " ")); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("BlackJack") + .AddField($"{dealerIcon} Dealer's Hand | Value: {bj.Dealer.GetHandValue()}", cStr); + + if (bj.CurrentUser is not null) + embed.WithFooter($"Player to make a choice: {bj.CurrentUser.DiscordUser}"); + + foreach (var p in bj.Players) + { + c = p.Cards.Select(x => x.GetEmojiString()).ToList(); + cStr = "-\t" + string.Concat(c.Select(x => x[..^1] + " ")); + cStr += "\n-\t" + string.Concat(c.Select(x => x.Last() + " ")); + var full = $"{p.DiscordUser.ToString().TrimTo(20)} | Bet: {N(p.Bet)} | Value: {p.GetHandValue()}"; + if (bj.State == Blackjack.GameState.Ended) + { + if (p.State == User.UserState.Lost) + full = "❌ " + full; + else + full = "✅ " + full; + } + else if (p == bj.CurrentUser) + full = "▶ " + full; + else if (p.State == User.UserState.Stand) + full = "⏹ " + full; + else if (p.State == User.UserState.Bust) + full = "💥 " + full; + else if (p.State == User.UserState.Blackjack) + full = "💰 " + full; + + embed.AddField(full, cStr); + } + + msg = await Response().Embed(embed).SendAsync(); + } + catch + { + } + } + + private string UserToString(User x) + { + var playerName = x.State == User.UserState.Bust + ? Format.Strikethrough(x.DiscordUser.ToString().TrimTo(30)) + : x.DiscordUser.ToString(); + + // var hand = $"{string.Concat(x.Cards.Select(y => "〖" + y.GetEmojiString() + "〗"))}"; + + + return $"{playerName} | Bet: {x.Bet}\n"; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Hit() + => InternalBlackJack(BjAction.Hit); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Stand() + => InternalBlackJack(BjAction.Stand); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Double() + => InternalBlackJack(BjAction.Double); + + private async Task InternalBlackJack(BjAction a) + { + if (!_service.Games.TryGetValue(ctx.Channel.Id, out var bj)) + return; + + if (a == BjAction.Hit) + await bj.Hit(ctx.User); + else if (a == BjAction.Stand) + await bj.Stand(ctx.User); + else if (a == BjAction.Double) + { + if (!await bj.Double(ctx.User)) + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + + await ctx.Message.DeleteAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs new file mode 100644 index 0000000..3bfb87c --- /dev/null +++ b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs @@ -0,0 +1,9 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common.Blackjack; + +namespace EllieBot.Modules.Gambling.Services; + +public class BlackJackService : IEService +{ + public ConcurrentDictionary Games { get; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs b/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs new file mode 100644 index 0000000..e21d2cd --- /dev/null +++ b/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs @@ -0,0 +1,329 @@ +#nullable disable +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Common.Blackjack; + +public class Blackjack +{ + public enum GameState + { + Starting, + Playing, + Ended + } + + public event Func StateUpdated; + public event Func GameEnded; + + private Deck Deck { get; } = new QuadDeck(); + public Dealer Dealer { get; set; } + + + public List Players { get; set; } = new(); + public GameState State { get; set; } = GameState.Starting; + public User CurrentUser { get; private set; } + + private TaskCompletionSource currentUserMove; + private readonly ICurrencyService _cs; + + private readonly SemaphoreSlim _locker = new(1, 1); + + public Blackjack(ICurrencyService cs) + { + _cs = cs; + Dealer = new(); + } + + public void Start() + => _ = GameLoop(); + + public async Task GameLoop() + { + try + { + //wait for players to join + await Task.Delay(20000); + await _locker.WaitAsync(); + try + { + State = GameState.Playing; + } + finally + { + _locker.Release(); + } + + await PrintState(); + //if no users joined the game, end it + if (!Players.Any()) + { + State = GameState.Ended; + _ = GameEnded?.Invoke(this); + return; + } + + //give 1 card to the dealer and 2 to each player + Dealer.Cards.Add(Deck.Draw()); + foreach (var usr in Players) + { + usr.Cards.Add(Deck.Draw()); + usr.Cards.Add(Deck.Draw()); + + if (usr.GetHandValue() == 21) + usr.State = User.UserState.Blackjack; + } + + //go through all users and ask them what they want to do + foreach (var usr in Players.Where(x => !x.Done)) + { + while (!usr.Done) + { + Log.Information("Waiting for {DiscordUser}'s move", usr.DiscordUser); + await PromptUserMove(usr); + } + } + + await PrintState(); + State = GameState.Ended; + await Task.Delay(2500); + Log.Information("Dealer moves"); + await DealerMoves(); + await PrintState(); + _ = GameEnded?.Invoke(this); + } + catch (Exception ex) + { + Log.Error(ex, "REPORT THE MESSAGE BELOW IN Ellie's Home SERVER PLEASE"); + State = GameState.Ended; + _ = GameEnded?.Invoke(this); + } + } + + private async Task PromptUserMove(User usr) + { + using var cts = new CancellationTokenSource(); + var pause = Task.Delay(20000, cts.Token); //10 seconds to decide + CurrentUser = usr; + currentUserMove = new(); + await PrintState(); + // either wait for the user to make an action and + // if he doesn't - stand + var finished = await Task.WhenAny(pause, currentUserMove.Task); + if (finished == pause) + await Stand(usr); + else + cts.Cancel(); + + CurrentUser = null; + currentUserMove = null; + } + + public async Task Join(IUser user, long bet) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Starting) + return false; + + if (Players.Count >= 5) + return false; + + if (!await _cs.RemoveAsync(user, bet, new("blackjack", "gamble"))) + return false; + + Players.Add(new(user, bet)); + _ = PrintState(); + return true; + } + finally + { + _locker.Release(); + } + } + + public async Task Stand(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Stand(cu); + + return false; + } + + public async Task Stand(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + u.State = User.UserState.Stand; + currentUserMove.TrySetResult(true); + return true; + } + finally + { + _locker.Release(); + } + } + + private async Task DealerMoves() + { + var hw = Dealer.GetHandValue(); + while (hw < 17 + || (hw == 17 + && Dealer.Cards.Count(x => x.Number == 1) > (Dealer.GetRawHandValue() - 17) / 10)) // hit on soft 17 + { + /* Dealer has + A 6 + That's 17, soft + hw == 17 => true + number of aces = 1 + 1 > 17-17 /10 => true + + AA 5 + That's 17, again soft, since one ace is worth 11, even though another one is 1 + hw == 17 => true + number of aces = 2 + 2 > 27 - 17 / 10 => true + + AA Q 5 + That's 17, but not soft, since both aces are worth 1 + hw == 17 => true + number of aces = 2 + 2 > 37 - 17 / 10 => false + * */ + Dealer.Cards.Add(Deck.Draw()); + hw = Dealer.GetHandValue(); + } + + if (hw > 21) + { + foreach (var usr in Players) + { + if (usr.State is User.UserState.Stand or User.UserState.Blackjack) + usr.State = User.UserState.Won; + else + usr.State = User.UserState.Lost; + } + } + else + { + foreach (var usr in Players) + { + if (usr.State == User.UserState.Blackjack) + usr.State = User.UserState.Won; + else if (usr.State == User.UserState.Stand) + usr.State = hw < usr.GetHandValue() ? User.UserState.Won : User.UserState.Lost; + else + usr.State = User.UserState.Lost; + } + } + + foreach (var usr in Players) + { + if (usr.State is User.UserState.Won or User.UserState.Blackjack) + await _cs.AddAsync(usr.DiscordUser.Id, usr.Bet * 2, new("blackjack", "win")); + } + } + + public async Task Double(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Double(cu); + + return false; + } + + public async Task Double(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + if (!await _cs.RemoveAsync(u.DiscordUser.Id, u.Bet, new("blackjack", "double"))) + return false; + + u.Bet *= 2; + + u.Cards.Add(Deck.Draw()); + + if (u.GetHandValue() == 21) + //blackjack + u.State = User.UserState.Blackjack; + else if (u.GetHandValue() > 21) + // user busted + u.State = User.UserState.Bust; + else + //with double you just get one card, and then you're done + u.State = User.UserState.Stand; + currentUserMove.TrySetResult(true); + + return true; + } + finally + { + _locker.Release(); + } + } + + public async Task Hit(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Hit(cu); + + return false; + } + + public async Task Hit(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + u.Cards.Add(Deck.Draw()); + + if (u.GetHandValue() == 21) + //blackjack + u.State = User.UserState.Blackjack; + else if (u.GetHandValue() > 21) + // user busted + u.State = User.UserState.Bust; + + currentUserMove.TrySetResult(true); + + return true; + } + finally + { + _locker.Release(); + } + } + + public Task PrintState() + { + if (StateUpdated is null) + return Task.CompletedTask; + return StateUpdated.Invoke(this); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/BlackJack/Player.cs b/src/EllieBot/Modules/Gambling/BlackJack/Player.cs new file mode 100644 index 0000000..fb238c1 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/BlackJack/Player.cs @@ -0,0 +1,57 @@ +#nullable disable +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Common.Blackjack; + +public abstract class Player +{ + public List Cards { get; } = new(); + + public int GetHandValue() + { + var val = GetRawHandValue(); + + // while the hand value is greater than 21, for each ace you have in the deck + // reduce the value by 10 until it drops below 22 + // (emulating the fact that ace is either a 1 or a 11) + var i = Cards.Count(x => x.Number == 1); + while (val > 21 && i-- > 0) + val -= 10; + return val; + } + + public int GetRawHandValue() + => Cards.Sum(x => x.Number == 1 ? 11 : x.Number >= 10 ? 10 : x.Number); +} + +public class Dealer : Player +{ +} + +public class User : Player +{ + public enum UserState + { + Waiting, + Stand, + Bust, + Blackjack, + Won, + Lost + } + + public UserState State { get; set; } = UserState.Waiting; + public long Bet { get; set; } + public IUser DiscordUser { get; } + + public bool Done + => State != UserState.Waiting; + + public User(IUser user, long bet) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bet); + + Bet = bet; + DiscordUser = user; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs b/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs new file mode 100644 index 0000000..45d2e89 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs @@ -0,0 +1,409 @@ +#nullable disable +using CommandLine; +using System.Collections.Immutable; + +namespace EllieBot.Modules.Gambling.Common.Connect4; + +public sealed class Connect4Game : IDisposable +{ + public enum Field //temporary most likely + { + Empty, + P1, + P2 + } + + public enum Phase + { + Joining, // waiting for second player to join + P1Move, + P2Move, + Ended + } + + public enum Result + { + Draw, + CurrentPlayerWon, + OtherPlayerWon + } + + public const int NUMBER_OF_COLUMNS = 7; + public const int NUMBER_OF_ROWS = 6; + + //public event Func OnGameStarted; + public event Func OnGameStateUpdated; + public event Func OnGameFailedToStart; + public event Func OnGameEnded; + + public Phase CurrentPhase { get; private set; } = Phase.Joining; + + public ImmutableArray GameState + => _gameState.ToImmutableArray(); + + public ImmutableArray<(ulong UserId, string Username)?> Players + => _players.ToImmutableArray(); + + public (ulong UserId, string Username) CurrentPlayer + => CurrentPhase == Phase.P1Move ? _players[0].Value : _players[1].Value; + + public (ulong UserId, string Username) OtherPlayer + => CurrentPhase == Phase.P2Move ? _players[0].Value : _players[1].Value; + + //state is bottom to top, left to right + private readonly Field[] _gameState = new Field[NUMBER_OF_ROWS * NUMBER_OF_COLUMNS]; + private readonly (ulong UserId, string Username)?[] _players = new (ulong, string)?[2]; + + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly Options _options; + private readonly ICurrencyService _cs; + private readonly EllieRandom _rng; + + private Timer playerTimeoutTimer; + + /* [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + */ + + public Connect4Game( + ulong userId, + string userName, + Options options, + ICurrencyService cs) + { + _players[0] = (userId, userName); + _options = options; + _cs = cs; + + _rng = new(); + for (var i = 0; i < NUMBER_OF_COLUMNS * NUMBER_OF_ROWS; i++) + _gameState[i] = Field.Empty; + } + + public void Initialize() + { + if (CurrentPhase != Phase.Joining) + return; + _ = Task.Run(async () => + { + await Task.Delay(15000); + await _locker.WaitAsync(); + try + { + if (_players[1] is null) + { + _ = OnGameFailedToStart?.Invoke(this); + CurrentPhase = Phase.Ended; + await _cs.AddAsync(_players[0].Value.UserId, _options.Bet, new("connect4", "refund")); + } + } + finally { _locker.Release(); } + }); + } + + public async Task Join(ulong userId, string userName, int bet) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Joining) //can't join if its not a joining phase + return false; + + if (_players[0].Value.UserId == userId) // same user can't join own game + return false; + + if (bet != _options.Bet) // can't join if bet amount is not the same + return false; + + if (!await _cs.RemoveAsync(userId, bet, new("connect4", "bet"))) // user doesn't have enough money to gamble + return false; + + if (_rng.Next(0, 2) == 0) //rolling from 0-1, if number is 0, join as first player + { + _players[1] = _players[0]; + _players[0] = (userId, userName); + } + else //else join as a second player + _players[1] = (userId, userName); + + CurrentPhase = Phase.P1Move; //start the game + playerTimeoutTimer = new(async _ => + { + await _locker.WaitAsync(); + try + { + EndGame(Result.OtherPlayerWon, OtherPlayer.UserId); + } + finally { _locker.Release(); } + }, + null, + TimeSpan.FromSeconds(_options.TurnTimer), + TimeSpan.FromSeconds(_options.TurnTimer)); + _ = OnGameStateUpdated?.Invoke(this); + + return true; + } + finally { _locker.Release(); } + } + + public async Task Input(ulong userId, int inputCol) + { + await _locker.WaitAsync(); + try + { + inputCol -= 1; + if (CurrentPhase is Phase.Ended or Phase.Joining) + return false; + + if (!((_players[0].Value.UserId == userId && CurrentPhase == Phase.P1Move) + || (_players[1].Value.UserId == userId && CurrentPhase == Phase.P2Move))) + return false; + + if (inputCol is < 0 or > NUMBER_OF_COLUMNS) //invalid input + return false; + + if (IsColumnFull(inputCol)) //can't play there event? + return false; + + var start = NUMBER_OF_ROWS * inputCol; + for (var i = start; i < start + NUMBER_OF_ROWS; i++) + { + if (_gameState[i] == Field.Empty) + { + _gameState[i] = GetPlayerPiece(userId); + break; + } + } + + //check winnning condition + // ok, i'll go from [0-2] in rows (and through all columns) and check upward if 4 are connected + + for (var i = 0; i < NUMBER_OF_ROWS - 3; i++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var j = 0; j < NUMBER_OF_COLUMNS; j++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[i + (j * NUMBER_OF_ROWS)]; + if (first != Field.Empty) + { + for (var k = 1; k < 4; k++) + { + var next = _gameState[i + k + (j * NUMBER_OF_ROWS)]; + if (next == first) + { + if (k == 3) + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + else + continue; + } + else + break; + } + } + } + } + + // i'll go [0-1] in columns (and through all rows) and check to the right if 4 are connected + for (var i = 0; i < NUMBER_OF_COLUMNS - 3; i++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var j = 0; j < NUMBER_OF_ROWS; j++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[j + (i * NUMBER_OF_ROWS)]; + if (first != Field.Empty) + { + for (var k = 1; k < 4; k++) + { + var next = _gameState[j + ((i + k) * NUMBER_OF_ROWS)]; + if (next == first) + { + if (k == 3) + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + else + continue; + } + else + break; + } + } + } + } + + //need to check diagonal now + for (var col = 0; col < NUMBER_OF_COLUMNS; col++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var row = 0; row < NUMBER_OF_ROWS; row++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[row + (col * NUMBER_OF_ROWS)]; + + if (first != Field.Empty) + { + var same = 1; + + //top left + for (var i = 1; i < 4; i++) + { + //while going top left, rows are increasing, columns are decreasing + var curRow = row + i; + var curCol = col - i; + + //check if current values are in range + if (curRow is >= NUMBER_OF_ROWS or < 0) + break; + if (curCol is < 0 or >= NUMBER_OF_COLUMNS) + break; + + var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)]; + if (cur == first) + same++; + else + break; + } + + if (same == 4) + { + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + break; + } + + same = 1; + + //top right + for (var i = 1; i < 4; i++) + { + //while going top right, rows are increasing, columns are increasing + var curRow = row + i; + var curCol = col + i; + + //check if current values are in range + if (curRow is >= NUMBER_OF_ROWS or < 0) + break; + if (curCol is < 0 or >= NUMBER_OF_COLUMNS) + break; + + var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)]; + if (cur == first) + same++; + else + break; + } + + if (same == 4) + { + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + break; + } + } + } + } + + //check draw? if it's even possible + if (_gameState.All(x => x != Field.Empty)) + EndGame(Result.Draw, null); + + if (CurrentPhase != Phase.Ended) + { + if (CurrentPhase == Phase.P1Move) + CurrentPhase = Phase.P2Move; + else + CurrentPhase = Phase.P1Move; + + ResetTimer(); + } + + _ = OnGameStateUpdated?.Invoke(this); + return true; + } + finally { _locker.Release(); } + } + + private void ResetTimer() + => playerTimeoutTimer.Change(TimeSpan.FromSeconds(_options.TurnTimer), + TimeSpan.FromSeconds(_options.TurnTimer)); + + private void EndGame(Result result, ulong? winId) + { + if (CurrentPhase == Phase.Ended) + return; + _ = OnGameEnded?.Invoke(this, result); + CurrentPhase = Phase.Ended; + + if (result == Result.Draw) + { + _cs.AddAsync(CurrentPlayer.UserId, _options.Bet, new("connect4", "draw")); + _cs.AddAsync(OtherPlayer.UserId, _options.Bet, new("connect4", "draw")); + return; + } + + if (winId is not null) + _cs.AddAsync(winId.Value, (long)(_options.Bet * 1.98), new("connect4", "win")); + } + + private Field GetPlayerPiece(ulong userId) + => _players[0].Value.UserId == userId ? Field.P1 : Field.P2; + + //column is full if there are no empty fields + private bool IsColumnFull(int column) + { + var start = NUMBER_OF_ROWS * column; + for (var i = start; i < start + NUMBER_OF_ROWS; i++) + { + if (_gameState[i] == Field.Empty) + return false; + } + + return true; + } + + public void Dispose() + { + OnGameFailedToStart = null; + OnGameStateUpdated = null; + OnGameEnded = null; + playerTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + + public class Options : IEllieCommandOptions + { + [Option('t', + "turn-timer", + Required = false, + Default = 15, + HelpText = "Turn time in seconds. It has to be between 5 and 60. Default 15.")] + public int TurnTimer { get; set; } = 15; + + [Option('b', "bet", Required = false, Default = 0, HelpText = "Amount you bet. Default 0.")] + public int Bet { get; set; } + + public void NormalizeOptions() + { + if (TurnTimer is < 5 or > 60) + TurnTimer = 15; + + if (Bet < 0) + Bet = 0; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs b/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs new file mode 100644 index 0000000..8210ad4 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs @@ -0,0 +1,205 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Connect4; +using EllieBot.Modules.Gambling.Services; +using System.Text; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class Connect4Commands : GamblingSubmodule + { + private static readonly string[] _numbers = + [ + ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:" + ]; + + private int RepostCounter + { + get => repostCounter; + set + { + if (value is < 0 or > 7) + repostCounter = 0; + else + repostCounter = value; + } + } + + private readonly DiscordSocketClient _client; + private readonly ICurrencyService _cs; + + private IUserMessage msg; + + private int repostCounter; + + public Connect4Commands(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService gamb) + : base(gamb) + { + _client = client; + _cs = cs; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Connect4(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new Connect4Game.Options(), args); + if (!await CheckBetOptional(options.Bet)) + return; + + var newGame = new Connect4Game(ctx.User.Id, ctx.User.ToString(), options, _cs); + Connect4Game game; + if ((game = _service.Connect4Games.GetOrAdd(ctx.Channel.Id, newGame)) != newGame) + { + if (game.CurrentPhase != Connect4Game.Phase.Joining) + return; + + newGame.Dispose(); + //means game already exists, try to join + await game.Join(ctx.User.Id, ctx.User.ToString(), options.Bet); + return; + } + + if (options.Bet > 0) + { + if (!await _cs.RemoveAsync(ctx.User.Id, options.Bet, new("connect4", "bet"))) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + _service.Connect4Games.TryRemove(ctx.Channel.Id, out _); + game.Dispose(); + return; + } + } + + game.OnGameStateUpdated += Game_OnGameStateUpdated; + game.OnGameFailedToStart += GameOnGameFailedToStart; + game.OnGameEnded += GameOnGameEnded; + _client.MessageReceived += ClientMessageReceived; + + game.Initialize(); + if (options.Bet == 0) + await Response().Confirm(strs.connect4_created).SendAsync(); + else + await Response().Error(strs.connect4_created_bet(N(options.Bet))).SendAsync(); + + Task ClientMessageReceived(SocketMessage arg) + { + if (ctx.Channel.Id != arg.Channel.Id) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var success = false; + if (int.TryParse(arg.Content, out var col)) + success = await game.Input(arg.Author.Id, col); + + if (success) + { + try { await arg.DeleteAsync(); } + catch { } + } + else + { + if (game.CurrentPhase is Connect4Game.Phase.Joining or Connect4Game.Phase.Ended) + return; + RepostCounter++; + if (RepostCounter == 0) + { + try { msg = await Response().Embed(msg.Embeds.First().ToEmbedBuilder()).SendAsync(); } + catch { } + } + } + }); + return Task.CompletedTask; + } + + Task GameOnGameFailedToStart(Connect4Game arg) + { + if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose)) + { + _client.MessageReceived -= ClientMessageReceived; + toDispose.Dispose(); + } + + return Response().Error(strs.connect4_failed_to_start).SendAsync(); + } + + Task GameOnGameEnded(Connect4Game arg, Connect4Game.Result result) + { + if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose)) + { + _client.MessageReceived -= ClientMessageReceived; + toDispose.Dispose(); + } + + string title; + if (result == Connect4Game.Result.CurrentPlayerWon) + { + title = GetText(strs.connect4_won(Format.Bold(arg.CurrentPlayer.Username), + Format.Bold(arg.OtherPlayer.Username))); + } + else if (result == Connect4Game.Result.OtherPlayerWon) + { + title = GetText(strs.connect4_won(Format.Bold(arg.OtherPlayer.Username), + Format.Bold(arg.CurrentPlayer.Username))); + } + else + title = GetText(strs.connect4_draw); + + return msg.ModifyAsync(x => x.Embed = _sender.CreateEmbed() + .WithTitle(title) + .WithDescription(GetGameStateText(game)) + .WithOkColor() + .Build()); + } + } + + private async Task Game_OnGameStateUpdated(Connect4Game game) + { + var embed = _sender.CreateEmbed() + .WithTitle($"{game.CurrentPlayer.Username} vs {game.OtherPlayer.Username}") + .WithDescription(GetGameStateText(game)) + .WithOkColor(); + + + if (msg is null) + msg = await Response().Embed(embed).SendAsync(); + else + await msg.ModifyAsync(x => x.Embed = embed.Build()); + } + + private string GetGameStateText(Connect4Game game) + { + var sb = new StringBuilder(); + + if (game.CurrentPhase is Connect4Game.Phase.P1Move or Connect4Game.Phase.P2Move) + sb.AppendLine(GetText(strs.connect4_player_to_move(Format.Bold(game.CurrentPlayer.Username)))); + + for (var i = Connect4Game.NUMBER_OF_ROWS; i > 0; i--) + { + for (var j = 0; j < Connect4Game.NUMBER_OF_COLUMNS; j++) + { + var cur = game.GameState[i + (j * Connect4Game.NUMBER_OF_ROWS) - 1]; + + if (cur == Connect4Game.Field.Empty) + sb.Append("⚫"); //black circle + else if (cur == Connect4Game.Field.P1) + sb.Append("🔴"); //red circle + else + sb.Append("🔵"); //blue circle + } + + sb.AppendLine(); + } + + for (var i = 0; i < Connect4Game.NUMBER_OF_COLUMNS; i++) + sb.Append(_numbers[i]); + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/CurrencyProvider.cs b/src/EllieBot/Modules/Gambling/CurrencyProvider.cs new file mode 100644 index 0000000..e4f4bc1 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/CurrencyProvider.cs @@ -0,0 +1,16 @@ +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public sealed class CurrencyProvider : ICurrencyProvider, IEService +{ + private readonly GamblingConfigService _cs; + + public CurrencyProvider(GamblingConfigService cs) + { + _cs = cs; + } + + public string GetCurrencySign() + => _cs.Data.Currency.Sign; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs b/src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs new file mode 100644 index 0000000..15bf7ff --- /dev/null +++ b/src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs @@ -0,0 +1,224 @@ +#nullable disable +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using System.Text.RegularExpressions; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class DiceRollCommands : EllieModule + { + private static readonly Regex _dndRegex = new(@"^(?\d+)d(?\d+)(?:\+(?\d+))?(?:\-(?\d+))?$", + RegexOptions.Compiled); + + private static readonly Regex _fudgeRegex = new(@"^(?\d+)d(?:F|f)$", RegexOptions.Compiled); + + private static readonly char[] _fateRolls = ['-', ' ', '+']; + private readonly IImageCache _images; + + public DiceRollCommands(IImageCache images) + => _images = images; + + [Cmd] + public async Task Roll() + { + var rng = new EllieRandom(); + var gen = rng.Next(1, 101); + + var num1 = gen / 10; + var num2 = gen % 10; + + using var img1 = await GetDiceAsync(num1); + using var img2 = await GetDiceAsync(num2); + using var img = new[] { img1, img2 }.Merge(out var format); + await using var ms = await img.ToStreamAsync(format); + + var fileName = $"dice.{format.FileExtensions.First()}"; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .AddField(GetText(strs.roll2), gen) + .WithImageUrl($"attachment://{fileName}"); + + await ctx.Channel.SendFileAsync(ms, + fileName, + embed: eb.Build()); + } + + [Cmd] + [Priority(1)] + public async Task Roll(int num) + => await InternalRoll(num, true); + + + [Cmd] + [Priority(1)] + public async Task Rolluo(int num = 1) + => await InternalRoll(num, false); + + [Cmd] + [Priority(0)] + public async Task Roll(string arg) + => await InternallDndRoll(arg, true); + + [Cmd] + [Priority(0)] + public async Task Rolluo(string arg) + => await InternallDndRoll(arg, false); + + private async Task InternalRoll(int num, bool ordered) + { + if (num is < 1 or > 30) + { + await Response().Error(strs.dice_invalid_number(1, 30)).SendAsync(); + return; + } + + var rng = new EllieRandom(); + + var dice = new List>(num); + var values = new List(num); + for (var i = 0; i < num; i++) + { + var randomNumber = rng.Next(1, 7); + var toInsert = dice.Count; + if (ordered) + { + if (randomNumber == 6 || dice.Count == 0) + toInsert = 0; + else if (randomNumber != 1) + { + for (var j = 0; j < dice.Count; j++) + { + if (values[j] < randomNumber) + { + toInsert = j; + break; + } + } + } + } + else + toInsert = dice.Count; + + dice.Insert(toInsert, await GetDiceAsync(randomNumber)); + values.Insert(toInsert, randomNumber); + } + + using var bitmap = dice.Merge(out var format); + await using var ms = bitmap.ToStream(format); + foreach (var d in dice) + d.Dispose(); + + var imageName = $"dice.{format.FileExtensions.First()}"; + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .AddField(GetText(strs.rolls), values.Select(x => Format.Code(x.ToString())).Join(' '), true) + .AddField(GetText(strs.total), values.Sum(), true) + .WithDescription(GetText(strs.dice_rolled_num(Format.Bold(values.Count.ToString())))) + .WithImageUrl($"attachment://{imageName}"); + + await ctx.Channel.SendFileAsync(ms, + imageName, + embed: eb.Build()); + } + + private async Task InternallDndRoll(string arg, bool ordered) + { + Match match; + if ((match = _fudgeRegex.Match(arg)).Length != 0 + && int.TryParse(match.Groups["n1"].ToString(), out var n1) + && n1 is > 0 and < 500) + { + var rng = new EllieRandom(); + + var rolls = new List(); + + for (var i = 0; i < n1; i++) + rolls.Add(_fateRolls[rng.Next(0, _fateRolls.Length)]); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(GetText(strs.dice_rolled_num(Format.Bold(n1.ToString())))) + .AddField(Format.Bold("Result"), + string.Join(" ", rolls.Select(c => Format.Code($"[{c}]")))); + + await Response().Embed(embed).SendAsync(); + } + else if ((match = _dndRegex.Match(arg)).Length != 0) + { + var rng = new EllieRandom(); + if (int.TryParse(match.Groups["n1"].ToString(), out n1) + && int.TryParse(match.Groups["n2"].ToString(), out var n2) + && n1 <= 50 + && n2 <= 100000 + && n1 > 0 + && n2 > 0) + { + if (!int.TryParse(match.Groups["add"].Value, out var add)) + add = 0; + if (!int.TryParse(match.Groups["sub"].Value, out var sub)) + sub = 0; + + var arr = new int[n1]; + for (var i = 0; i < n1; i++) + arr[i] = rng.Next(1, n2 + 1); + + var sum = arr.Sum(); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(GetText(strs.dice_rolled_num(n1 + $"`1 - {n2}`"))) + .AddField(Format.Bold(GetText(strs.rolls)), + string.Join(" ", + (ordered ? arr.OrderBy(x => x).AsEnumerable() : arr).Select(x + => Format.Code(x.ToString())))) + .AddField(Format.Bold("Sum"), + sum + " + " + add + " - " + sub + " = " + (sum + add - sub)); + await Response().Embed(embed).SendAsync(); + } + } + } + + [Cmd] + public async Task NRoll([Leftover] string range) + { + int rolled; + if (range.Contains("-")) + { + var arr = range.Split('-').Take(2).Select(int.Parse).ToArray(); + if (arr[0] > arr[1]) + { + await Response().Error(strs.second_larger_than_first).SendAsync(); + return; + } + + rolled = new EllieRandom().Next(arr[0], arr[1] + 1); + } + else + rolled = new EllieRandom().Next(0, int.Parse(range) + 1); + + await Response().Confirm(strs.dice_rolled(Format.Bold(rolled.ToString()))).SendAsync(); + } + + private async Task> GetDiceAsync(int num) + { + if (num is < 0 or > 10) + throw new ArgumentOutOfRangeException(nameof(num)); + + if (num == 10) + { + using var imgOne = Image.Load(await _images.GetDiceAsync(1)); + using var imgZero = Image.Load(await _images.GetDiceAsync(0)); + return new[] { imgOne, imgZero }.Merge(); + } + + return Image.Load(await _images.GetDiceAsync(num)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs b/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs new file mode 100644 index 0000000..dd10cf1 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs @@ -0,0 +1,234 @@ +#nullable disable +using Ellie.Econ; +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class DrawCommands : GamblingSubmodule + { + private static readonly ConcurrentDictionary _allDecks = new(); + private readonly IImageCache _images; + + public DrawCommands(IImageCache images, GamblingConfigService gcs) : base(gcs) + => _images = images; + + private async Task InternalDraw(int count, ulong? guildId = null) + { + if (count is < 1 or > 10) + throw new ArgumentOutOfRangeException(nameof(count)); + + var cards = guildId is null ? new() : _allDecks.GetOrAdd(ctx.Guild, _ => new()); + var images = new List>(); + var cardObjects = new List(); + for (var i = 0; i < count; i++) + { + if (cards.CardPool.Count == 0 && i != 0) + { + try + { + await Response().Error(strs.no_more_cards).SendAsync(); + } + catch + { + // ignored + } + + break; + } + + var currentCard = cards.Draw(); + cardObjects.Add(currentCard); + var image = await GetCardImageAsync(currentCard); + images.Add(image); + } + + var imgName = "cards.jpg"; + using var img = images.Merge(); + foreach (var i in images) + i.Dispose(); + + var eb = _sender.CreateEmbed() + .WithOkColor(); + + var toSend = string.Empty; + if (cardObjects.Count == 5) + eb.AddField(GetText(strs.hand_value), Deck.GetHandValue(cardObjects), true); + + if (guildId is not null) + toSend += GetText(strs.cards_left(Format.Bold(cards.CardPool.Count.ToString()))); + + eb.WithDescription(toSend) + .WithAuthor(ctx.User) + .WithImageUrl($"attachment://{imgName}"); + + if (count > 1) + eb.AddField(GetText(strs.cards), count.ToString(), true); + + await using var imageStream = await img.ToStreamAsync(); + await ctx.Channel.SendFileAsync(imageStream, + imgName, + embed: eb.Build()); + } + + private async Task> GetCardImageAsync(RegularCard currentCard) + { + var cardName = currentCard.GetName().ToLowerInvariant().Replace(' ', '_'); + var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg"); + return Image.Load(cardBytes); + } + + private async Task> GetCardImageAsync(Deck.Card currentCard) + { + var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_'); + var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg"); + return Image.Load(cardBytes); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Draw(int num = 1) + { + if (num < 1) + return; + + if (num > 10) + num = 10; + + await InternalDraw(num, ctx.Guild.Id); + } + + [Cmd] + public async Task DrawNew(int num = 1) + { + if (num < 1) + return; + + if (num > 10) + num = 10; + + await InternalDraw(num); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task DeckShuffle() + { + //var channel = (ITextChannel)ctx.Channel; + + _allDecks.AddOrUpdate(ctx.Guild, + _ => new(), + (_, c) => + { + c.Restart(); + return c; + }); + + await Response().Confirm(strs.deck_reshuffled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputValueGuess val, InputColorGuess? col = null) + => BetDrawInternal(amount, val, col); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputColorGuess col, InputValueGuess? val = null) + => BetDrawInternal(amount, val, col); + + public async Task BetDrawInternal(long amount, InputValueGuess? val, InputColorGuess? col) + { + if (amount <= 0) + return; + + var res = await _service.BetDrawAsync(ctx.User.Id, + amount, + (byte?)val, + (byte?)col); + + if (!res.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(result.Card.GetEmoji()) + .AddField(GetText(strs.guess), GetGuessInfo(val, col), true) + .AddField(GetText(strs.card), GetCardInfo(result.Card), true) + .AddField(GetText(strs.won), N((long)result.Won), false) + .WithImageUrl("attachment://card.png"); + + using var img = await GetCardImageAsync(result.Card); + await using var imgStream = await img.ToStreamAsync(); + await ctx.Channel.SendFileAsync(imgStream, "card.png", embed: eb.Build()); + } + + private string GetGuessInfo(InputValueGuess? valG, InputColorGuess? colG) + { + var val = valG switch + { + InputValueGuess.H => "Hi ⬆️", + InputValueGuess.L => "Lo ⬇️", + _ => "❓" + }; + + var col = colG switch + { + InputColorGuess.Red => "R 🔴", + InputColorGuess.Black => "B ⚫", + _ => "❓" + }; + + return $"{val} / {col}"; + } + private string GetCardInfo(RegularCard card) + { + var val = (int)card.Value switch + { + < 7 => "Lo ⬇️", + > 7 => "Hi ⬆️", + _ => "7 💀" + }; + + var col = card.Value == RegularValue.Seven + ? "7 💀" + : card.Suit switch + { + RegularSuit.Diamonds or RegularSuit.Hearts => "R 🔴", + _ => "B ⚫" + }; + + return $"{val} / {col}"; + } + + public enum InputValueGuess + { + High = 0, + H = 0, + Hi = 0, + Low = 1, + L = 1, + Lo = 1, + } + + public enum InputColorGuess + { + R = 0, + Red = 0, + B = 1, + Bl = 1, + Black = 1, + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/EconomyResult.cs b/src/EllieBot/Modules/Gambling/EconomyResult.cs new file mode 100644 index 0000000..12a00f8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/EconomyResult.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Services; + +public sealed class EconomyResult +{ + public decimal Cash { get; init; } + public decimal Planted { get; init; } + public decimal Waifus { get; init; } + public decimal OnePercent { get; init; } + public decimal Bank { get; init; } + public long Bot { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs new file mode 100644 index 0000000..c5e836c --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs @@ -0,0 +1,60 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Events; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class CurrencyEventsCommands : GamblingSubmodule + { + public CurrencyEventsCommands(GamblingConfigService gamblingConf) + : base(gamblingConf) + { + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + [OwnerOnly] + public async Task EventStart(CurrencyEvent.Type ev, params string[] options) + { + var (opts, _) = OptionsParser.ParseFrom(new EventOptions(), options); + if (!await _service.TryCreateEventAsync(ctx.Guild.Id, ctx.Channel.Id, ev, opts, GetEmbed)) + await Response().Error(strs.start_event_fail).SendAsync(); + } + + private EmbedBuilder GetEmbed(CurrencyEvent.Type type, EventOptions opts, long currentPot) + => type switch + { + CurrencyEvent.Type.Reaction => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.event_title(type.ToString()))) + .WithDescription(GetReactionDescription(opts.Amount, currentPot)) + .WithFooter(GetText(strs.event_duration_footer(opts.Hours))), + CurrencyEvent.Type.GameStatus => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.event_title(type.ToString()))) + .WithDescription(GetGameStatusDescription(opts.Amount, currentPot)) + .WithFooter(GetText(strs.event_duration_footer(opts.Hours))), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + private string GetReactionDescription(long amount, long potSize) + { + var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : N(potSize)); + + return GetText(strs.new_reaction_event(CurrencySign, Format.Bold(N(amount)), potSizeStr)); + } + + private string GetGameStatusDescription(long amount, long potSize) + { + var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : potSize + CurrencySign); + + return GetText(strs.new_gamestatus_event(CurrencySign, Format.Bold(N(amount)), potSizeStr)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs new file mode 100644 index 0000000..39160ff --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs @@ -0,0 +1,70 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Events; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Services; + +public class CurrencyEventsService : IEService +{ + private readonly DiscordSocketClient _client; + private readonly ICurrencyService _cs; + private readonly GamblingConfigService _configService; + + private readonly ConcurrentDictionary _events = new(); + private readonly IMessageSenderService _sender; + + public CurrencyEventsService(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService configService, + IMessageSenderService sender) + { + _client = client; + _cs = cs; + _configService = configService; + _sender = sender; + } + + public async Task TryCreateEventAsync( + ulong guildId, + ulong channelId, + CurrencyEvent.Type type, + EventOptions opts, + Func embed) + { + var g = _client.GetGuild(guildId); + if (g?.GetChannel(channelId) is not ITextChannel ch) + return false; + + ICurrencyEvent ce; + + if (type == CurrencyEvent.Type.Reaction) + ce = new ReactionEvent(_client, _cs, g, ch, opts, _configService.Data, _sender, embed); + else if (type == CurrencyEvent.Type.GameStatus) + ce = new GameStatusEvent(_client, _cs, g, ch, opts, _sender, embed); + else + return false; + + var added = _events.TryAdd(guildId, ce); + if (added) + { + try + { + ce.OnEnded += OnEventEnded; + await ce.StartEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error starting event"); + _events.TryRemove(guildId, out ce); + return false; + } + } + + return added; + } + + private Task OnEventEnded(ulong gid) + { + _events.TryRemove(gid, out _); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/EventOptions.cs b/src/EllieBot/Modules/Gambling/Events/EventOptions.cs new file mode 100644 index 0000000..3d0eb3f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/EventOptions.cs @@ -0,0 +1,39 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Gambling.Common.Events; + +public class EventOptions : IEllieCommandOptions +{ + [Option('a', "amount", Required = false, Default = 100, HelpText = "Amount of currency each user receives.")] + public long Amount { get; set; } = 100; + + [Option('p', + "pot-size", + Required = false, + Default = 0, + HelpText = "The maximum amount of currency that can be rewarded. 0 means no limit.")] + public long PotSize { get; set; } + + //[Option('t', "type", Required = false, Default = "reaction", HelpText = "Type of the event. reaction, gamestatus or joinserver.")] + //public string TypeString { get; set; } = "reaction"; + [Option('d', + "duration", + Required = false, + Default = 24, + HelpText = "Number of hours the event should run for. Default 24.")] + public int Hours { get; set; } = 24; + + + public void NormalizeOptions() + { + if (Amount < 0) + Amount = 100; + if (PotSize < 0) + PotSize = 0; + if (Hours <= 0) + Hours = 24; + if (PotSize != 0 && PotSize < Amount) + PotSize = 0; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs b/src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs new file mode 100644 index 0000000..aeb23c0 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs @@ -0,0 +1,198 @@ +#nullable disable +using EllieBot.Db.Models; +using System.Collections.Concurrent; + +namespace EllieBot.Modules.Gambling.Common.Events; + +public class GameStatusEvent : ICurrencyEvent +{ + public event Func OnEnded; + private long PotSize { get; set; } + public bool Stopped { get; private set; } + public bool PotEmptied { get; private set; } + private readonly DiscordSocketClient _client; + private readonly IGuild _guild; + private IUserMessage msg; + private readonly ICurrencyService _cs; + private readonly long _amount; + + private readonly Func _embedFunc; + private readonly bool _isPotLimited; + private readonly ITextChannel _channel; + private readonly ConcurrentHashSet _awardedUsers = new(); + private readonly ConcurrentQueue _toAward = new(); + private readonly Timer _t; + private readonly Timer _timeout; + private readonly EventOptions _opts; + + private readonly string _code; + + private readonly char[] _sneakyGameStatusChars = Enumerable.Range(48, 10) + .Concat(Enumerable.Range(65, 26)) + .Concat(Enumerable.Range(97, 26)) + .Select(x => (char)x) + .ToArray(); + + private readonly object _stopLock = new(); + + private readonly object _potLock = new(); + private readonly IMessageSenderService _sender; + + public GameStatusEvent( + DiscordSocketClient client, + ICurrencyService cs, + SocketGuild g, + ITextChannel ch, + EventOptions opt, + IMessageSenderService sender, + Func embedFunc) + { + _client = client; + _guild = g; + _cs = cs; + _amount = opt.Amount; + PotSize = opt.PotSize; + _embedFunc = embedFunc; + _isPotLimited = PotSize > 0; + _channel = ch; + _opts = opt; + _sender = sender; + // generate code + _code = new(_sneakyGameStatusChars.Shuffle().Take(5).ToArray()); + + _t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2)); + if (_opts.Hours > 0) + _timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan); + } + + private void EventTimeout(object state) + => _ = StopEvent(); + + private async void OnTimerTick(object state) + { + var potEmpty = PotEmptied; + var toAward = new List(); + while (_toAward.TryDequeue(out var x)) + toAward.Add(x); + + if (!toAward.Any()) + return; + + try + { + await _cs.AddBulkAsync(toAward, + _amount, + new("event", "gamestatus") + ); + + if (_isPotLimited) + { + await msg.ModifyAsync(m => + { + m.Embed = GetEmbed(PotSize).Build(); + }); + } + + Log.Information("Game status event awarded {Count} users {Amount} currency.{Remaining}", + toAward.Count, + _amount, + _isPotLimited ? $" {PotSize} left." : ""); + + if (potEmpty) + _ = StopEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in OnTimerTick in gamestatusevent"); + } + } + + public async Task StartEvent() + { + msg = await _sender.Response(_channel).Embed(GetEmbed(_opts.PotSize)).SendAsync(); + await _client.SetGameAsync(_code); + _client.MessageDeleted += OnMessageDeleted; + _client.MessageReceived += HandleMessage; + _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + } + + private EmbedBuilder GetEmbed(long pot) + => _embedFunc(CurrencyEvent.Type.GameStatus, _opts, pot); + + private async Task OnMessageDeleted(Cacheable message, Cacheable cacheable) + { + if (message.Id == msg.Id) + await StopEvent(); + } + + public Task StopEvent() + { + lock (_stopLock) + { + if (Stopped) + return Task.CompletedTask; + Stopped = true; + _client.MessageDeleted -= OnMessageDeleted; + _client.MessageReceived -= HandleMessage; + _t.Change(Timeout.Infinite, Timeout.Infinite); + _timeout?.Change(Timeout.Infinite, Timeout.Infinite); + _ = _client.SetGameAsync(null); + try + { + _ = msg.DeleteAsync(); + } + catch { } + + _ = OnEnded?.Invoke(_guild.Id); + } + + return Task.CompletedTask; + } + + private Task HandleMessage(SocketMessage message) + { + _ = Task.Run(async () => + { + if (message.Author is not IGuildUser gu // no unknown users, as they could be bots, or alts + || gu.IsBot // no bots + || message.Content != _code // code has to be the same + || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5) // no recently created accounts + return; + // there has to be money left in the pot + // and the user wasn't rewarded + if (_awardedUsers.Add(message.Author.Id) && TryTakeFromPot()) + { + _toAward.Enqueue(message.Author.Id); + if (_isPotLimited && PotSize < _amount) + PotEmptied = true; + } + + try + { + await message.DeleteAsync(new() + { + RetryMode = RetryMode.AlwaysFail + }); + } + catch { } + }); + return Task.CompletedTask; + } + + private bool TryTakeFromPot() + { + if (_isPotLimited) + { + lock (_potLock) + { + if (PotSize < _amount) + return false; + + PotSize -= _amount; + return true; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs b/src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs new file mode 100644 index 0000000..57b96d9 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common; + +public interface ICurrencyEvent +{ + event Func OnEnded; + Task StopEvent(); + Task StartEvent(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs b/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs new file mode 100644 index 0000000..6f02747 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs @@ -0,0 +1,197 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Common.Events; + +public class ReactionEvent : ICurrencyEvent +{ + public event Func OnEnded; + private long PotSize { get; set; } + public bool Stopped { get; private set; } + public bool PotEmptied { get; private set; } + private readonly DiscordSocketClient _client; + private readonly IGuild _guild; + private IUserMessage msg; + private IEmote emote; + private readonly ICurrencyService _cs; + private readonly long _amount; + + private readonly Func _embedFunc; + private readonly bool _isPotLimited; + private readonly ITextChannel _channel; + private readonly ConcurrentHashSet _awardedUsers = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue _toAward = new(); + private readonly Timer _t; + private readonly Timer _timeout; + private readonly bool _noRecentlyJoinedServer; + private readonly EventOptions _opts; + private readonly GamblingConfig _config; + + private readonly object _stopLock = new(); + + private readonly object _potLock = new(); + private readonly IMessageSenderService _sender; + + public ReactionEvent( + DiscordSocketClient client, + ICurrencyService cs, + SocketGuild g, + ITextChannel ch, + EventOptions opt, + GamblingConfig config, + IMessageSenderService sender, + Func embedFunc) + { + _client = client; + _guild = g; + _cs = cs; + _amount = opt.Amount; + PotSize = opt.PotSize; + _embedFunc = embedFunc; + _isPotLimited = PotSize > 0; + _channel = ch; + _noRecentlyJoinedServer = false; + _opts = opt; + _config = config; + _sender = sender; + + _t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2)); + if (_opts.Hours > 0) + _timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan); + } + + private void EventTimeout(object state) + => _ = StopEvent(); + + private async void OnTimerTick(object state) + { + var potEmpty = PotEmptied; + var toAward = new List(); + while (_toAward.TryDequeue(out var x)) + toAward.Add(x); + + if (!toAward.Any()) + return; + + try + { + await _cs.AddBulkAsync(toAward, _amount, new("event", "reaction")); + + if (_isPotLimited) + { + await msg.ModifyAsync(m => + { + m.Embed = GetEmbed(PotSize).Build(); + }); + } + + Log.Information("Reaction Event awarded {Count} users {Amount} currency.{Remaining}", + toAward.Count, + _amount, + _isPotLimited ? $" {PotSize} left." : ""); + + if (potEmpty) + _ = StopEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error adding bulk currency to users"); + } + } + + public async Task StartEvent() + { + if (Emote.TryParse(_config.Currency.Sign, out var parsedEmote)) + emote = parsedEmote; + else + emote = new Emoji(_config.Currency.Sign); + msg = await _sender.Response(_channel).Embed(GetEmbed(_opts.PotSize)).SendAsync(); + await msg.AddReactionAsync(emote); + _client.MessageDeleted += OnMessageDeleted; + _client.ReactionAdded += HandleReaction; + _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + } + + private EmbedBuilder GetEmbed(long pot) + => _embedFunc(CurrencyEvent.Type.Reaction, _opts, pot); + + private async Task OnMessageDeleted(Cacheable message, Cacheable cacheable) + { + if (message.Id == msg.Id) + await StopEvent(); + } + + public Task StopEvent() + { + lock (_stopLock) + { + if (Stopped) + return Task.CompletedTask; + + Stopped = true; + _client.MessageDeleted -= OnMessageDeleted; + _client.ReactionAdded -= HandleReaction; + _t.Change(Timeout.Infinite, Timeout.Infinite); + _timeout?.Change(Timeout.Infinite, Timeout.Infinite); + try + { + _ = msg.DeleteAsync(); + } + catch { } + + _ = OnEnded?.Invoke(_guild.Id); + } + + return Task.CompletedTask; + } + + private Task HandleReaction( + Cacheable message, + Cacheable cacheable, + SocketReaction r) + { + _ = Task.Run(() => + { + if (emote.Name != r.Emote.Name) + return; + if ((r.User.IsSpecified + ? r.User.Value + : null) is not IGuildUser gu // no unknown users, as they could be bots, or alts + || message.Id != msg.Id // same message + || gu.IsBot // no bots + || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5 // no recently created accounts + || (_noRecentlyJoinedServer + && // if specified, no users who joined the server in the last 24h + (gu.JoinedAt is null + || (DateTime.UtcNow - gu.JoinedAt.Value).TotalDays + < 1))) // and no users for who we don't know when they joined + return; + // there has to be money left in the pot + // and the user wasn't rewarded + if (_awardedUsers.Add(r.UserId) && TryTakeFromPot()) + { + _toAward.Enqueue(r.UserId); + if (_isPotLimited && PotSize < _amount) + PotEmptied = true; + } + }); + return Task.CompletedTask; + } + + private bool TryTakeFromPot() + { + if (_isPotLimited) + { + lock (_potLock) + { + if (PotSize < _amount) + return false; + + PotSize -= _amount; + return true; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs b/src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs new file mode 100644 index 0000000..2704495 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs @@ -0,0 +1,140 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class FlipCoinCommands : GamblingSubmodule + { + public enum BetFlipGuess : byte + { + H = 0, + Head = 0, + Heads = 0, + T = 1, + Tail = 1, + Tails = 1 + } + + private static readonly EllieRandom _rng = new(); + private readonly IImageCache _images; + private readonly ICurrencyService _cs; + private readonly ImagesConfig _ic; + + public FlipCoinCommands( + IImageCache images, + ImagesConfig ic, + ICurrencyService cs, + GamblingConfigService gss) + : base(gss) + { + _ic = ic; + _images = images; + _cs = cs; + } + + [Cmd] + public async Task Flip(int count = 1) + { + if (count is > 10 or < 1) + { + await Response().Error(strs.flip_invalid(10)).SendAsync(); + return; + } + + var headCount = 0; + var tailCount = 0; + var imgs = new Image[count]; + var headsArr = await _images.GetHeadsImageAsync(); + var tailsArr = await _images.GetTailsImageAsync(); + + var result = await _service.FlipAsync(count); + + for (var i = 0; i < result.Length; i++) + { + if (result[i].Side == 0) + { + imgs[i] = Image.Load(headsArr); + headCount++; + } + else + { + imgs[i] = Image.Load(tailsArr); + tailCount++; + } + } + + using var img = imgs.Merge(out var format); + await using var stream = await img.ToStreamAsync(format); + foreach (var i in imgs) + i.Dispose(); + + var imgName = $"coins.{format.FileExtensions.First()}"; + + var msg = count != 1 + ? Format.Bold(GetText(strs.flip_results(count, headCount, tailCount))) + : GetText(strs.flipped(headCount > 0 + ? Format.Bold(GetText(strs.heads)) + : Format.Bold(GetText(strs.tails)))); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(msg) + .WithImageUrl($"attachment://{imgName}"); + + await ctx.Channel.SendFileAsync(stream, + imgName, + embed: eb.Build()); + } + + [Cmd] + public async Task Betflip([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, BetFlipGuess guess) + { + if (!await CheckBetMandatory(amount) || amount == 1) + return; + + var res = await _service.BetFlipAsync(ctx.User.Id, amount, (byte)guess); + if (!res.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + Uri imageToSend; + var coins = _ic.Data.Coins; + if (result.Side == 0) + { + imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)]; + } + else + { + imageToSend = coins.Tails[_rng.Next(0, coins.Tails.Length)]; + } + + string str; + var won = (long)result.Won; + if (won > 0) + { + str = Format.Bold(GetText(strs.flip_guess(N(won)))); + } + else + { + str = Format.Bold(GetText(strs.better_luck)); + } + + await Response().Embed(_sender.CreateEmbed() + .WithAuthor(ctx.User) + .WithDescription(str) + .WithOkColor() + .WithImageUrl(imageToSend.ToString())).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs b/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs new file mode 100644 index 0000000..6c16b9f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct FlipResult +{ + public long Won { get; init; } + public int Side { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Gambling.cs b/src/EllieBot/Modules/Gambling/Gambling.cs new file mode 100644 index 0000000..428f756 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Gambling.cs @@ -0,0 +1,1032 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Bank; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Modules.Utility.Services; +using EllieBot.Services.Currency; +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using EllieBot.Modules.Gambling.Rps; +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling : GamblingModule +{ + private readonly IGamblingService _gs; + private readonly DbService _db; + private readonly ICurrencyService _cs; + private readonly DiscordSocketClient _client; + private readonly NumberFormatInfo _enUsCulture; + private readonly DownloadTracker _tracker; + private readonly GamblingConfigService _configService; + private readonly IBankService _bank; + private readonly IPatronageService _ps; + private readonly IRemindService _remind; + private readonly GamblingTxTracker _gamblingTxTracker; + + private IUserMessage rdMsg; + + public Gambling( + IGamblingService gs, + DbService db, + ICurrencyService currency, + DiscordSocketClient client, + DownloadTracker tracker, + GamblingConfigService configService, + IBankService bank, + IPatronageService ps, + IRemindService remind, + GamblingTxTracker gamblingTxTracker) + : base(configService) + { + _gs = gs; + _db = db; + _cs = currency; + _client = client; + _bank = bank; + _ps = ps; + _remind = remind; + _gamblingTxTracker = gamblingTxTracker; + + _enUsCulture = new CultureInfo("en-US", false).NumberFormat; + _enUsCulture.NumberDecimalDigits = 0; + _enUsCulture.NumberGroupSeparator = " "; + _tracker = tracker; + _configService = configService; + } + + public async Task GetBalanceStringAsync(ulong userId) + { + var bal = await _cs.GetBalanceAsync(userId); + return N(bal); + } + + [Cmd] + public async Task BetStats() + { + var stats = await _gamblingTxTracker.GetAllAsync(); + + var eb = _sender.CreateEmbed() + .WithOkColor(); + + var str = "` Feature `|`   Bet  `|`Paid Out`|`  RoI  `\n"; + str += "――――――――――――――――――――\n"; + foreach (var stat in stats) + { + var perc = (stat.PaidOut / stat.Bet).ToString("P2", Culture); + str += $"`{stat.Feature.PadBoth(9)}`" + + $"|`{stat.Bet.ToString("N0").PadLeft(8, ' ')}`" + + $"|`{stat.PaidOut.ToString("N0").PadLeft(8, ' ')}`" + + $"|`{perc.PadLeft(6, ' ')}`\n"; + } + + var bet = stats.Sum(x => x.Bet); + var paidOut = stats.Sum(x => x.PaidOut); + + if (bet == 0) + bet = 1; + + var tPerc = (paidOut / bet).ToString("P2", Culture); + str += "――――――――――――――――――――\n"; + str += $"` {("TOTAL").PadBoth(7)}` " + + $"|**{N(bet).PadLeft(8, ' ')}**" + + $"|**{N(paidOut).PadLeft(8, ' ')}**" + + $"|`{tPerc.PadLeft(6, ' ')}`"; + + eb.WithDescription(str); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + public async Task Economy() + { + var ec = await _service.GetEconomyAsync(); + decimal onePercent = 0; + + // This stops the top 1% from owning more than 100% of the money + if (ec.Cash > 0) + { + onePercent = ec.OnePercent / (ec.Cash - ec.Bot); + } + + // [21:03] Bob Page: Kinda remids me of US economy + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.economy_state)) + .AddField(GetText(strs.currency_owned), N(ec.Cash - ec.Bot)) + .AddField(GetText(strs.currency_one_percent), (onePercent * 100).ToString("F2") + "%") + .AddField(GetText(strs.currency_planted), N(ec.Planted)) + .AddField(GetText(strs.owned_waifus_total), N(ec.Waifus)) + .AddField(GetText(strs.bot_currency), N(ec.Bot)) + .AddField(GetText(strs.bank_accounts), N(ec.Bank)) + .AddField(GetText(strs.total), N(ec.Cash + ec.Planted + ec.Waifus + ec.Bank)) + .WithOkColor(); + + // ec.Cash already contains ec.Bot as it's the total of all values in the CurrencyAmount column of the DiscordUser table + await Response().Embed(embed).SendAsync(); + } + + private static readonly FeatureLimitKey _timelyKey = new FeatureLimitKey() + { + Key = "timely:extra_percent", + PrettyName = "Timely" + }; + + private async Task RemindTimelyAction(SocketMessageComponent smc, DateTime when) + { + var tt = TimestampTag.FromDateTime(when, TimestampTagStyles.Relative); + + await _remind.AddReminderAsync(ctx.User.Id, + ctx.User.Id, + ctx.Guild?.Id, + true, + when, + GetText(strs.timely_time), + ReminderType.Timely); + + await smc.RespondConfirmAsync(_sender, GetText(strs.remind_timely(tt)), ephemeral: true); + } + + private EllieInteractionBase CreateRemindMeInteraction(int period) + => _inter + .Create(ctx.User.Id, + new ButtonBuilder( + label: "Remind me", + emote: Emoji.Parse("⏰"), + customId: "timely:remind_me"), + (smc) => RemindTimelyAction(smc, DateTime.UtcNow.Add(TimeSpan.FromHours(period))) + ); + + [Cmd] + public async Task Timely() + { + var val = Config.Timely.Amount; + var period = Config.Timely.Cooldown; + if (val <= 0 || period <= 0) + { + await Response().Error(strs.timely_none).SendAsync(); + return; + } + + var inter = CreateRemindMeInteraction(period); + + if (await _service.ClaimTimelyAsync(ctx.User.Id, period) is { } rem) + { + // Removes timely button if there is a timely reminder in DB + if (_service.UserHasTimelyReminder(ctx.User.Id)) + { + inter = null; + } + + var now = DateTime.UtcNow; + var relativeTag = TimestampTag.FromDateTime(now.Add(rem), TimestampTagStyles.Relative); + await Response().Pending(strs.timely_already_claimed(relativeTag)).Interaction(inter).SendAsync(); + return; + } + + var result = await _ps.TryGetFeatureLimitAsync(_timelyKey, ctx.User.Id, 0); + + val = (int)(val * (1 + (result.Quota! * 0.01f))); + + await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim")); + + await Response().Confirm(strs.timely(N(val), period)).Interaction(inter).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task TimelyReset() + { + await _service.RemoveAllTimelyClaimsAsync(); + await Response().Confirm(strs.timely_reset).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task TimelySet(int amount, int period = 24) + { + if (amount < 0 || period < 0) + { + return; + } + + _configService.ModifyConfig(gs => + { + gs.Timely.Amount = amount; + gs.Timely.Cooldown = period; + }); + + if (amount == 0) + { + await Response().Confirm(strs.timely_set_none).SendAsync(); + } + else + { + await Response() + .Confirm(strs.timely_set(Format.Bold(N(amount)), Format.Bold(period.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Raffle([Leftover] IRole role = null) + { + role ??= ctx.Guild.EveryoneRole; + + var members = (await role.GetMembersAsync()).Where(u => u.Status != UserStatus.Offline); + var membersArray = members as IUser[] ?? members.ToArray(); + if (membersArray.Length == 0) + { + return; + } + + var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)]; + await Response() + .Confirm("🎟 " + GetText(strs.raffled_user), + $"**{usr.Username}**", + footer: $"ID: {usr.Id}") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RaffleAny([Leftover] IRole role = null) + { + role ??= ctx.Guild.EveryoneRole; + + var members = await role.GetMembersAsync(); + var membersArray = members as IUser[] ?? members.ToArray(); + if (membersArray.Length == 0) + { + return; + } + + var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)]; + await Response() + .Confirm("🎟 " + GetText(strs.raffled_user), + $"**{usr.Username}**", + footer: $"ID: {usr.Id}") + .SendAsync(); + } + + [Cmd] + [Priority(2)] + public Task CurrencyTransactions(int page = 1) + => InternalCurrencyTransactions(ctx.User.Id, page); + + [Cmd] + [OwnerOnly] + [Priority(0)] + public Task CurrencyTransactions([Leftover] IUser usr) + => InternalCurrencyTransactions(usr.Id, 1); + + [Cmd] + [OwnerOnly] + [Priority(1)] + public Task CurrencyTransactions(IUser usr, int page) + => InternalCurrencyTransactions(usr.Id, page); + + private async Task InternalCurrencyTransactions(ulong userId, int page) + { + if (--page < 0) + { + return; + } + + List trs; + await using (var uow = _db.GetDbContext()) + { + trs = await uow.Set().GetPageFor(userId, page); + } + + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.transactions(((SocketGuild)ctx.Guild)?.GetUser(userId)?.ToString() + ?? $"{userId}"))) + .WithOkColor(); + + var sb = new StringBuilder(); + foreach (var tr in trs) + { + var change = tr.Amount >= 0 ? "🔵" : "🔴"; + var kwumId = new kwum(tr.Id).ToString(); + var date = $"#{Format.Code(kwumId)} `〖{GetFormattedCurtrDate(tr)}〗`"; + + sb.AppendLine($"\\{change} {date} {Format.Bold(N(tr.Amount))}"); + var transactionString = GetHumanReadableTransaction(tr.Type, tr.Extra, tr.OtherId); + if (transactionString is not null) + { + sb.AppendLine(transactionString); + } + + if (!string.IsNullOrWhiteSpace(tr.Note)) + { + sb.AppendLine($"\t`Note:` {tr.Note.TrimTo(50)}"); + } + } + + embed.WithDescription(sb.ToString()); + embed.WithFooter(GetText(strs.page(page + 1))); + await Response().Embed(embed).SendAsync(); + } + + private static string GetFormattedCurtrDate(CurrencyTransaction ct) + => $"{ct.DateAdded:HH:mm yyyy-MM-dd}"; + + [Cmd] + public async Task CurrencyTransaction(kwum id) + { + int intId = id; + await using var uow = _db.GetDbContext(); + + var tr = await uow.Set() + .ToLinqToDBTable() + .Where(x => x.Id == intId && x.UserId == ctx.User.Id) + .FirstOrDefaultAsync(); + + if (tr is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed().WithOkColor(); + + eb.WithAuthor(ctx.User); + eb.WithTitle(GetText(strs.transaction)); + eb.WithDescription(new kwum(tr.Id).ToString()); + eb.AddField("Amount", N(tr.Amount)); + eb.AddField("Type", tr.Type, true); + eb.AddField("Extra", tr.Extra, true); + + if (tr.OtherId is ulong other) + { + eb.AddField("From Id", other); + } + + if (!string.IsNullOrWhiteSpace(tr.Note)) + { + eb.AddField("Note", tr.Note); + } + + eb.WithFooter(GetFormattedCurtrDate(tr)); + + await Response().Embed(eb).SendAsync(); + } + + private string GetHumanReadableTransaction(string type, string subType, ulong? maybeUserId) + => (type, subType, maybeUserId) switch + { + ("gift", var name, ulong userId) => GetText(strs.curtr_gift(name, userId)), + ("award", var name, ulong userId) => GetText(strs.curtr_award(name, userId)), + ("take", var name, ulong userId) => GetText(strs.curtr_take(name, userId)), + ("blackjack", _, _) => $"Blackjack - {subType}", + ("wheel", _, _) => $"Lucky Ladder - {subType}", + ("lula", _, _) => $"Lucky Ladder - {subType}", + ("rps", _, _) => $"Rock Paper Scissors - {subType}", + (null, _, _) => null, + (_, null, _) => null, + (_, _, ulong userId) => $"{type} - {subType} | [{userId}]", + _ => $"{type} - {subType}" + }; + + [Cmd] + [Priority(0)] + public async Task Cash(ulong userId) + { + var cur = await GetBalanceStringAsync(userId); + await Response().Confirm(strs.has(Format.Code(userId.ToString()), cur)).SendAsync(); + } + + private async Task BankAction(SocketMessageComponent smc) + { + var balance = await _bank.GetBalanceAsync(ctx.User.Id); + + await N(balance) + .Pipe(strs.bank_balance) + .Pipe(GetText) + .Pipe(text => smc.RespondConfirmAsync(_sender, text, ephemeral: true)); + } + + private EllieInteractionBase CreateCashInteraction() + => _inter.Create(ctx.User.Id, + new ButtonBuilder( + customId: "cash:bank_show_balance", + emote: new Emoji("🏦")), + BankAction); + + [Cmd] + [Priority(1)] + public async Task Cash([Leftover] IUser user = null) + { + user ??= ctx.User; + var cur = await GetBalanceStringAsync(user.Id); + + var inter = user == ctx.User + ? CreateCashInteraction() + : null; + + await Response() + .Confirm( + user.ToString() + .Pipe(Format.Bold) + .With(cur) + .Pipe(strs.has)) + .Interaction(inter) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task Give( + [OverrideTypeReader(typeof(BalanceTypeReader))] + long amount, + IGuildUser receiver, + [Leftover] string msg) + { + if (amount <= 0 || ctx.User.Id == receiver.Id || receiver.IsBot) + { + return; + } + + if (!await _cs.TransferAsync(_sender, ctx.User, receiver, amount, msg, N(amount))) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + await Response().Confirm(strs.gifted(N(amount), Format.Bold(receiver.ToString()), ctx.User)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task Give([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, [Leftover] IGuildUser receiver) + => Give(amount, receiver, null); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(0)] + public Task Award(long amount, IGuildUser usr, [Leftover] string msg) + => Award(amount, usr.Id, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(1)] + public Task Award(long amount, [Leftover] IGuildUser usr) + => Award(amount, usr.Id); + + [Cmd] + [OwnerOnly] + [Priority(2)] + public async Task Award(long amount, ulong usrId, [Leftover] string msg = null) + { + if (amount <= 0) + { + return; + } + + var usr = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(usrId); + + if (usr is null) + { + await Response().Error(strs.user_not_found).SendAsync(); + return; + } + + await _cs.AddAsync(usr.Id, amount, new("award", ctx.User.ToString()!, msg, ctx.User.Id)); + await Response().Confirm(strs.awarded(N(amount), $"<@{usrId}>", ctx.User)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(3)] + public async Task Award(long amount, [Leftover] IRole role) + { + var users = (await ctx.Guild.GetUsersAsync()).Where(u => u.GetRoles().Contains(role)).ToList(); + + await _cs.AddBulkAsync(users.Select(x => x.Id).ToList(), + amount, + new("award", ctx.User.ToString()!, role.Name, ctx.User.Id)); + + await Response() + .Confirm(strs.mass_award(N(amount), + Format.Bold(users.Count.ToString()), + Format.Bold(role.Name))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(0)] + public async Task Take(long amount, [Leftover] IRole role) + { + var users = (await role.GetMembersAsync()).ToList(); + + await _cs.RemoveBulkAsync(users.Select(x => x.Id).ToList(), + amount, + new("take", ctx.User.ToString()!, null, ctx.User.Id)); + + await Response() + .Confirm(strs.mass_take(N(amount), + Format.Bold(users.Count.ToString()), + Format.Bold(role.Name))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(1)] + public async Task Take(long amount, [Leftover] IGuildUser user) + { + if (amount <= 0) + { + return; + } + + var extra = new TxData("take", ctx.User.ToString()!, null, ctx.User.Id); + + if (await _cs.RemoveAsync(user.Id, amount, extra)) + { + await Response().Confirm(strs.take(N(amount), Format.Bold(user.ToString()))).SendAsync(); + } + else + { + await Response().Error(strs.take_fail(N(amount), Format.Bold(user.ToString()), CurrencySign)).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task Take(long amount, [Leftover] ulong usrId) + { + if (amount <= 0) + { + return; + } + + var extra = new TxData("take", ctx.User.ToString()!, null, ctx.User.Id); + + if (await _cs.RemoveAsync(usrId, amount, extra)) + { + await Response().Confirm(strs.take(N(amount), $"<@{usrId}>")).SendAsync(); + } + else + { + await Response().Error(strs.take_fail(N(amount), Format.Code(usrId.ToString()), CurrencySign)).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RollDuel(IUser u) + { + if (ctx.User.Id == u.Id) + { + return; + } + + //since the challenge is created by another user, we need to reverse the ids + //if it gets removed, means challenge is accepted + if (_service.Duels.TryRemove((ctx.User.Id, u.Id), out var game)) + { + await game.StartGame(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RollDuel([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, IUser u) + { + if (ctx.User.Id == u.Id) + { + return; + } + + if (amount <= 0) + { + return; + } + + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.roll_duel)); + + var description = string.Empty; + + var game = new RollDuelGame(_cs, _client.CurrentUser.Id, ctx.User.Id, u.Id, amount); + //means challenge is just created + if (_service.Duels.TryGetValue((ctx.User.Id, u.Id), out var other)) + { + if (other.Amount != amount) + { + await Response().Error(strs.roll_duel_already_challenged).SendAsync(); + } + else + { + await RollDuel(u); + } + + return; + } + + if (_service.Duels.TryAdd((u.Id, ctx.User.Id), game)) + { + game.OnGameTick += GameOnGameTick; + game.OnEnded += GameOnEnded; + + await Response() + .Confirm(strs.roll_duel_challenge(Format.Bold(ctx.User.ToString()), + Format.Bold(u.ToString()), + Format.Bold(N(amount)))) + .SendAsync(); + } + + async Task GameOnGameTick(RollDuelGame arg) + { + var rolls = arg.Rolls.Last(); + description += $@"{Format.Bold(ctx.User.ToString())} rolled **{rolls.Item1}** +{Format.Bold(u.ToString())} rolled **{rolls.Item2}** +-- +"; + embed = embed.WithDescription(description); + + if (rdMsg is null) + { + rdMsg = await Response().Embed(embed).SendAsync(); + } + else + { + await rdMsg.ModifyAsync(x => { x.Embed = embed.Build(); }); + } + } + + async Task GameOnEnded(RollDuelGame rdGame, RollDuelGame.Reason reason) + { + try + { + if (reason == RollDuelGame.Reason.Normal) + { + var winner = rdGame.Winner == rdGame.P1 ? ctx.User : u; + description += $"\n**{winner}** Won {N((long)(rdGame.Amount * 2 * 0.98))}"; + + embed = embed.WithDescription(description); + + await rdMsg.ModifyAsync(x => x.Embed = embed.Build()); + } + else if (reason == RollDuelGame.Reason.Timeout) + { + await Response().Error(strs.roll_duel_timeout).SendAsync(); + } + else if (reason == RollDuelGame.Reason.NoFunds) + { + await Response().Error(strs.roll_duel_no_funds).SendAsync(); + } + } + finally + { + _service.Duels.TryRemove((u.Id, ctx.User.Id), out _); + } + } + } + + [Cmd] + public async Task BetRoll([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + { + return; + } + + var maybeResult = await _gs.BetRollAsync(ctx.User.Id, amount); + if (!maybeResult.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + + var win = (long)result.Won; + string str; + if (win > 0) + { + str = GetText(strs.br_win(N(win), result.Threshold + (result.Roll == 100 ? " 👑" : ""))); + } + else + { + str = GetText(strs.better_luck); + } + + var eb = _sender.CreateEmbed() + .WithAuthor(ctx.User) + .WithDescription(Format.Bold(str)) + .AddField(GetText(strs.roll2), result.Roll.ToString(CultureInfo.InvariantCulture)) + .WithOkColor(); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + [EllieOptions] + [Priority(0)] + public Task Leaderboard(params string[] args) + => Leaderboard(1, args); + + [Cmd] + [EllieOptions] + [Priority(1)] + public async Task Leaderboard(int page = 1, params string[] args) + { + if (--page < 0) + { + return; + } + + var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args); + + // List cleanRichest; + // it's pointless to have clean on dm context + if (ctx.Guild is null) + { + opts.Clean = false; + } + + + async Task> GetTopRichest(int curPage) + { + if (opts.Clean) + { + await ctx.Channel.TriggerTypingAsync(); + await _tracker.EnsureUsersDownloadedAsync(ctx.Guild); + + await using var uow = _db.GetDbContext(); + + var cleanRichest = await uow.Set() + .GetTopRichest(_client.CurrentUser.Id, 0, 1000); + + var sg = (SocketGuild)ctx.Guild!; + return cleanRichest.Where(x => sg.GetUser(x.UserId) is not null).ToList(); + } + else + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetTopRichest(_client.CurrentUser.Id, curPage); + } + } + + var res = Response() + .Paginated(); + + await Response() + .Paginated() + .PageItems(GetTopRichest) + .TotalElements(900) + .PageSize(9) + .CurrentPage(page) + .Page((toSend, curPage) => + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(CurrencySign + " " + GetText(strs.leaderboard)); + + if (!toSend.Any()) + { + embed.WithDescription(GetText(strs.no_user_on_this_page)); + return Task.FromResult(embed); + } + + for (var i = 0; i < toSend.Count; i++) + { + var x = toSend[i]; + var usrStr = x.ToString().TrimTo(20, true); + + var j = i; + embed.AddField("#" + ((9 * curPage) + j + 1) + " " + usrStr, N(x.CurrencyAmount), true); + } + + return Task.FromResult(embed); + }) + .SendAsync(); + } + + public enum InputRpsPick : byte + { + R = 0, + Rock = 0, + Rocket = 0, + P = 1, + Paper = 1, + Paperclip = 1, + S = 2, + Scissors = 2 + } + + [Cmd] + public async Task Rps(InputRpsPick pick, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default) + { + static string GetRpsPick(InputRpsPick p) + { + switch (p) + { + case InputRpsPick.R: + return "🚀"; + case InputRpsPick.P: + return "📎"; + default: + return "✂️"; + } + } + + if (!await CheckBetOptional(amount) || amount == 1) + return; + + var res = await _gs.RpsAsync(ctx.User.Id, amount, (byte)pick); + + if (!res.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed(); + + string msg; + if (result.Result == RpsResultType.Draw) + { + msg = GetText(strs.rps_draw(GetRpsPick(pick))); + } + else if (result.Result == RpsResultType.Win) + { + if ((long)result.Won > 0) + embed.AddField(GetText(strs.won), N((long)result.Won)); + + msg = GetText(strs.rps_win(ctx.User.Mention, + GetRpsPick(pick), + GetRpsPick((InputRpsPick)result.ComputerPick))); + } + else + { + msg = GetText(strs.rps_win(ctx.Client.CurrentUser.Mention, + GetRpsPick((InputRpsPick)result.ComputerPick), + GetRpsPick(pick))); + } + + embed + .WithOkColor() + .WithDescription(msg); + + await Response().Embed(embed).SendAsync(); + } + + private static readonly ImmutableArray _emojis = + new[] { "⬆", "↖", "⬅", "↙", "⬇", "↘", "➡", "↗" }.ToImmutableArray(); + + [Cmd] + public async Task LuckyLadder([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + return; + + var res = await _gs.LulaAsync(ctx.User.Id, amount); + if (!res.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var multis = result.Multipliers; + + var sb = new StringBuilder(); + foreach (var multi in multis) + { + sb.Append($"╠══╣"); + + if (multi == result.Multiplier) + sb.Append($"{Format.Bold($"x{multi:0.##}")} ⬅️"); + else + sb.Append($"||x{multi:0.##}||"); + + sb.AppendLine(); + } + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(sb.ToString()) + .AddField(GetText(strs.multiplier), $"{result.Multiplier:0.##}x", true) + .AddField(GetText(strs.won), $"{(long)result.Won}", true) + .WithAuthor(ctx.User); + + + await Response().Embed(eb).SendAsync(); + } + + + public enum GambleTestTarget + { + Slot, + Betroll, + Betflip, + BetflipT, + BetDraw, + BetDrawHL, + BetDrawRB, + Lula, + Rps, + } + + [Cmd] + [OwnerOnly] + public async Task BetTest() + { + var values = Enum.GetValues() + .Select(x => $"`{x}`") + .Join(", "); + + await Response().Confirm(GetText(strs.available_tests), values).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task BetTest(GambleTestTarget target, int tests = 1000) + { + if (tests <= 0) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var streak = 0; + var maxW = 0; + var maxL = 0; + + var dict = new Dictionary(); + for (var i = 0; i < tests; i++) + { + var multi = target switch + { + GambleTestTarget.BetDraw => (await _gs.BetDrawAsync(ctx.User.Id, 0, 1, 0)).AsT0.Multiplier, + GambleTestTarget.BetDrawRB => (await _gs.BetDrawAsync(ctx.User.Id, 0, null, 1)).AsT0.Multiplier, + GambleTestTarget.BetDrawHL => (await _gs.BetDrawAsync(ctx.User.Id, 0, 0, null)).AsT0.Multiplier, + GambleTestTarget.Slot => (await _gs.SlotAsync(ctx.User.Id, 0)).AsT0.Multiplier, + GambleTestTarget.Betflip => (await _gs.BetFlipAsync(ctx.User.Id, 0, 0)).AsT0.Multiplier, + GambleTestTarget.BetflipT => (await _gs.BetFlipAsync(ctx.User.Id, 0, 1)).AsT0.Multiplier, + GambleTestTarget.Lula => (await _gs.LulaAsync(ctx.User.Id, 0)).AsT0.Multiplier, + GambleTestTarget.Rps => (await _gs.RpsAsync(ctx.User.Id, 0, (byte)(i % 3))).AsT0.Multiplier, + GambleTestTarget.Betroll => (await _gs.BetRollAsync(ctx.User.Id, 0)).AsT0.Multiplier, + _ => throw new ArgumentOutOfRangeException(nameof(target)) + }; + + if (dict.ContainsKey(multi)) + dict[multi] += 1; + else + dict.Add(multi, 1); + + if (multi < 1) + { + if (streak <= 0) + --streak; + else + streak = -1; + + maxL = Math.Max(maxL, -streak); + } + else if (multi > 1) + { + if (streak >= 0) + ++streak; + else + streak = 1; + + maxW = Math.Max(maxW, streak); + } + } + + var sb = new StringBuilder(); + decimal payout = 0; + foreach (var key in dict.Keys.OrderByDescending(x => x)) + { + sb.AppendLine($"x**{key}** occured `{dict[key]}` times. {dict[key] * 1.0f / tests * 100}%"); + payout += key * dict[key]; + } + + sb.AppendLine(); + sb.AppendLine($"Longest win streak: `{maxW}`"); + sb.AppendLine($"Longest lose streak: `{maxL}`"); + + await Response() + .Confirm(GetText(strs.test_results_for(target)), + sb.ToString(), + footer: $"Total Bet: {tests} | Payout: {payout:F0} | {payout * 1.0M / tests * 100}%") + .SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/GamblingConfig.cs b/src/EllieBot/Modules/Gambling/GamblingConfig.cs new file mode 100644 index 0000000..0100c88 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/GamblingConfig.cs @@ -0,0 +1,404 @@ +#nullable disable +using Cloneable; +using EllieBot.Common.Yml; +using SixLabors.ImageSharp.PixelFormats; +using YamlDotNet.Serialization; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Gambling.Common; + +[Cloneable] +public sealed partial class GamblingConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 2; + + [Comment("""Currency settings""")] + public CurrencyConfig Currency { get; set; } + + [Comment("""Minimum amount users can bet (>=0)""")] + public int MinBet { get; set; } = 0; + + [Comment(""" + Maximum amount users can bet + Set 0 for unlimited + """)] + public int MaxBet { get; set; } = 0; + + [Comment("""Settings for betflip command""")] + public BetFlipConfig BetFlip { get; set; } + + [Comment("""Settings for betroll command""")] + public BetRollConfig BetRoll { get; set; } + + [Comment("""Automatic currency generation settings.""")] + public GenerationConfig Generation { get; set; } + + [Comment(""" + Settings for timely command + (letting people claim X amount of currency every Y hours) + """)] + public TimelyConfig Timely { get; set; } + + [Comment("""How much will each user's owned currency decay over time.""")] + public DecayConfig Decay { get; set; } + + [Comment("""What is the bot's cut on some transactions""")] + public BotCutConfig BotCuts { get; set; } + + [Comment("""Settings for LuckyLadder command""")] + public LuckyLadderSettings LuckyLadder { get; set; } + + [Comment("""Settings related to waifus""")] + public WaifuConfig Waifu { get; set; } + + [Comment(""" + Amount of currency selfhosters will get PER pledged dollar CENT. + 1 = 100 currency per $. Used almost exclusively on public ellie. + """)] + public decimal PatreonCurrencyPerCent { get; set; } = 1; + + [Comment(""" + Currency reward per vote. + This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting + """)] + public long VoteReward { get; set; } = 100; + + [Comment("""Slot config""")] + public SlotsConfig Slots { get; set; } + + public GamblingConfig() + { + BetRoll = new(); + Waifu = new(); + Currency = new(); + BetFlip = new(); + Generation = new(); + Timely = new(); + Decay = new(); + Slots = new(); + LuckyLadder = new(); + BotCuts = new(); + } +} + +public class CurrencyConfig +{ + [Comment("""What is the emoji/character which represents the currency""")] + public string Sign { get; set; } = "💵"; + + [Comment("""What is the name of the currency""")] + public string Name { get; set; } = "Ellie Money"; + + [Comment(""" + For how long (in days) will the transactions be kept in the database (curtrs) + Set 0 to disable cleanup (keep transactions forever) + """)] + public int TransactionsLifetime { get; set; } = 0; +} + +[Cloneable] +public partial class TimelyConfig +{ + [Comment(""" + How much currency will the users get every time they run .timely command + setting to 0 or less will disable this feature + """)] + public int Amount { get; set; } = 0; + + [Comment(""" + How often (in hours) can users claim currency with .timely command + setting to 0 or less will disable this feature + """)] + public int Cooldown { get; set; } = 24; +} + +[Cloneable] +public partial class BetFlipConfig +{ + [Comment("""Bet multiplier if user guesses correctly""")] + public decimal Multiplier { get; set; } = 1.95M; +} + +[Cloneable] +public partial class BetRollConfig +{ + [Comment(""" + When betroll is played, user will roll a number 0-100. + This setting will describe which multiplier is used for when the roll is higher than the given number. + Doesn't have to be ordered. + """)] + public BetRollPair[] Pairs { get; set; } = Array.Empty(); + + public BetRollConfig() + => Pairs = + [ + new() + { + WhenAbove = 99, + MultiplyBy = 10 + }, + new() + { + WhenAbove = 90, + MultiplyBy = 4 + }, + new() + { + WhenAbove = 66, + MultiplyBy = 2 + } + ]; +} + +[Cloneable] +public partial class GenerationConfig +{ + [Comment(""" + when currency is generated, should it also have a random password + associated with it which users have to type after the .pick command + in order to get it + """)] + public bool HasPassword { get; set; } = true; + + [Comment(""" + Every message sent has a certain % chance to generate the currency + specify the percentage here (1 being 100%, 0 being 0% - for example + default is 0.02, which is 2% + """)] + public decimal Chance { get; set; } = 0.02M; + + [Comment("""How many seconds have to pass for the next message to have a chance to spawn currency""")] + public int GenCooldown { get; set; } = 10; + + [Comment("""Minimum amount of currency that can spawn""")] + public int MinAmount { get; set; } = 1; + + [Comment(""" + Maximum amount of currency that can spawn. + Set to the same value as MinAmount to always spawn the same amount + """)] + public int MaxAmount { get; set; } = 1; +} + +[Cloneable] +public partial class DecayConfig +{ + [Comment(""" + Percentage of user's current currency which will be deducted every 24h. + 0 - 1 (1 is 100%, 0.5 50%, 0 disabled) + """)] + public decimal Percent { get; set; } = 0; + + [Comment("""Maximum amount of user's currency that can decay at each interval. 0 for unlimited.""")] + public int MaxDecay { get; set; } = 0; + + [Comment("""Only users who have more than this amount will have their currency decay.""")] + public int MinThreshold { get; set; } = 99; + + [Comment("""How often, in hours, does the decay run. Default is 24 hours""")] + public int HourInterval { get; set; } = 24; +} + +[Cloneable] +public partial class LuckyLadderSettings +{ + [Comment("""Self-Explanatory. Has to have 8 values, otherwise the command won't work.""")] + public decimal[] Multipliers { get; set; } + + public LuckyLadderSettings() + => Multipliers = [2.4M, 1.7M, 1.5M, 1.2M, 0.5M, 0.3M, 0.2M, 0.1M]; +} + +[Cloneable] +public sealed partial class WaifuConfig +{ + [Comment("""Minimum price a waifu can have""")] + public long MinPrice { get; set; } = 50; + + public MultipliersData Multipliers { get; set; } = new(); + + [Comment(""" + Settings for periodic waifu price decay. + Waifu price decays only if the waifu has no claimer. + """)] + public WaifuDecayConfig Decay { get; set; } = new(); + + [Comment(""" + List of items available for gifting. + If negative is true, gift will instead reduce waifu value. + """)] + public List Items { get; set; } = []; + + public WaifuConfig() + => Items = + [ + new("🥔", 5, "Potato"), + new("🍪", 10, "Cookie"), + new("🥖", 20, "Bread"), + new("🍭", 30, "Lollipop"), + new("🌹", 50, "Rose"), + new("🍺", 70, "Beer"), + new("🌮", 85, "Taco"), + new("💌", 100, "LoveLetter"), + new("🥛", 125, "Milk"), + new("🍕", 150, "Pizza"), + new("🍫", 200, "Chocolate"), + new("🍦", 250, "Icecream"), + new("🍣", 300, "Sushi"), + new("🍚", 400, "Rice"), + new("🍉", 500, "Watermelon"), + new("🍱", 600, "Bento"), + new("🎟", 800, "MovieTicket"), + new("🍰", 1000, "Cake"), + new("📔", 1500, "Book"), + new("🐱", 2000, "Cat"), + new("🐶", 2001, "Dog"), + new("🐼", 2500, "Panda"), + new("💄", 3000, "Lipstick"), + new("👛", 3500, "Purse"), + new("📱", 4000, "iPhone"), + new("👗", 4500, "Dress"), + new("💻", 5000, "Laptop"), + new("🎻", 7500, "Violin"), + new("🎹", 8000, "Piano"), + new("🚗", 9000, "Car"), + new("💍", 10000, "Ring"), + new("🛳", 12000, "Ship"), + new("🏠", 15000, "House"), + new("🚁", 20000, "Helicopter"), + new("🚀", 30000, "Spaceship"), + new("🌕", 50000, "Moon") + ]; + + public class WaifuDecayConfig + { + [Comment(""" + Percentage (0 - 100) of the waifu value to reduce. + Set 0 to disable + For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$) + """)] + public int Percent { get; set; } = 0; + + [Comment("""How often to decay waifu values, in hours""")] + public int HourInterval { get; set; } = 24; + + [Comment(""" + Minimum waifu price required for the decay to be applied. + For example if this value is set to 300, any waifu with the price 300 or less will not experience decay. + """)] + public long MinPrice { get; set; } = 300; + } +} + +[Cloneable] +public sealed partial class MultipliersData +{ + [Comment(""" + Multiplier for waifureset. Default 150. + Formula (at the time of writing this): + price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up + """)] + public int WaifuReset { get; set; } = 150; + + [Comment(""" + The minimum amount of currency that you have to pay + in order to buy a waifu who doesn't have a crush on you. + Default is 1.1 + Example: If a waifu is worth 100, you will have to pay at least 100 * NormalClaim currency to claim her. + (100 * 1.1 = 110) + """)] + public decimal NormalClaim { get; set; } = 1.1m; + + [Comment(""" + The minimum amount of currency that you have to pay + in order to buy a waifu that has a crush on you. + Default is 0.88 + Example: If a waifu is worth 100, you will have to pay at least 100 * CrushClaim currency to claim her. + (100 * 0.88 = 88) + """)] + public decimal CrushClaim { get; set; } = 0.88M; + + [Comment(""" + When divorcing a waifu, her new value will be her current value multiplied by this number. + Default 0.75 (meaning will lose 25% of her value) + """)] + public decimal DivorceNewValue { get; set; } = 0.75M; + + [Comment(""" + All gift prices will be multiplied by this number. + Default 1 (meaning no effect) + """)] + public decimal AllGiftPrices { get; set; } = 1.0M; + + [Comment(""" + What percentage of the value of the gift will a waifu gain when she's gifted. + Default 0.95 (meaning 95%) + Example: If a waifu is worth 1000, and she receives a gift worth 100, her new value will be 1095) + """)] + public decimal GiftEffect { get; set; } = 0.95M; + + [Comment(""" + What percentage of the value of the gift will a waifu lose when she's gifted a gift marked as 'negative'. + Default 0.5 (meaning 50%) + Example: If a waifu is worth 1000, and she receives a negative gift worth 100, her new value will be 950) + """)] + public decimal NegativeGiftEffect { get; set; } = 0.50M; +} + +public sealed class SlotsConfig +{ + [Comment("""Hex value of the color which the numbers on the slot image will have.""")] + public Rgba32 CurrencyFontColor { get; set; } = Color.Red; +} + +[Cloneable] +public sealed partial class WaifuItemModel +{ + public string ItemEmoji { get; set; } + public long Price { get; set; } + public string Name { get; set; } + + [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] + public bool Negative { get; set; } + + public WaifuItemModel() + { + } + + public WaifuItemModel( + string itemEmoji, + long price, + string name, + bool negative = false) + { + ItemEmoji = itemEmoji; + Price = price; + Name = name; + Negative = negative; + } + + + public override string ToString() + => Name; +} + +[Cloneable] +public sealed partial class BetRollPair +{ + public int WhenAbove { get; set; } + public float MultiplyBy { get; set; } +} + +[Cloneable] +public sealed partial class BotCutConfig +{ + [Comment(""" + Shop sale cut percentage. + Whenever a user buys something from the shop, bot will take a cut equal to this percentage. + The rest goes to the user who posted the item/role/whatever to the shop. + This is a good way to reduce the amount of currency in circulation therefore keeping the inflation in check. + Default 0.1 (10%). + """)] + public decimal ShopSaleCut { get; set; } = 0.1m; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/GamblingConfigService.cs b/src/EllieBot/Modules/Gambling/GamblingConfigService.cs new file mode 100644 index 0000000..6fb8ff2 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/GamblingConfigService.cs @@ -0,0 +1,194 @@ +#nullable disable +using EllieBot.Common.Configs; +using EllieBot.Modules.Gambling.Common; + +namespace EllieBot.Modules.Gambling.Services; + +public sealed class GamblingConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/gambling.yml"; + private static readonly TypedKey _changeKey = new("config.gambling.updated"); + + public override string Name + => "gambling"; + + private readonly IEnumerable _antiGiftSeed = new[] + { + new WaifuItemModel("🥀", 100, "WiltedRose", true), new WaifuItemModel("✂️", 1000, "Haircut", true), + new WaifuItemModel("🧻", 10000, "ToiletPaper", true) + }; + + public GamblingConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("currency.name", + gs => gs.Currency.Name, + ConfigParsers.String, + ConfigPrinters.ToString); + + AddParsedProp("currency.sign", + gs => gs.Currency.Sign, + ConfigParsers.String, + ConfigPrinters.ToString); + + AddParsedProp("minbet", + gs => gs.MinBet, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("maxbet", + gs => gs.MaxBet, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("gen.min", + gs => gs.Generation.MinAmount, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 1); + + AddParsedProp("gen.max", + gs => gs.Generation.MaxAmount, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 1); + + AddParsedProp("gen.cd", + gs => gs.Generation.GenCooldown, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + AddParsedProp("gen.chance", + gs => gs.Generation.Chance, + decimal.TryParse, + ConfigPrinters.ToString, + val => val is >= 0 and <= 1); + + AddParsedProp("gen.has_pw", + gs => gs.Generation.HasPassword, + bool.TryParse, + ConfigPrinters.ToString); + + AddParsedProp("bf.multi", + gs => gs.BetFlip.Multiplier, + decimal.TryParse, + ConfigPrinters.ToString, + val => val >= 1); + + AddParsedProp("waifu.min_price", + gs => gs.Waifu.MinPrice, + long.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("waifu.multi.reset", + gs => gs.Waifu.Multipliers.WaifuReset, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("waifu.multi.crush_claim", + gs => gs.Waifu.Multipliers.CrushClaim, + decimal.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("waifu.multi.normal_claim", + gs => gs.Waifu.Multipliers.NormalClaim, + decimal.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + AddParsedProp("waifu.multi.divorce_value", + gs => gs.Waifu.Multipliers.DivorceNewValue, + decimal.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + AddParsedProp("waifu.multi.all_gifts", + gs => gs.Waifu.Multipliers.AllGiftPrices, + decimal.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + AddParsedProp("waifu.multi.gift_effect", + gs => gs.Waifu.Multipliers.GiftEffect, + decimal.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("waifu.multi.negative_gift_effect", + gs => gs.Waifu.Multipliers.NegativeGiftEffect, + decimal.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("decay.percent", + gs => gs.Decay.Percent, + decimal.TryParse, + ConfigPrinters.ToString, + val => val is >= 0 and <= 1); + + AddParsedProp("decay.maxdecay", + gs => gs.Decay.MaxDecay, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("decay.threshold", + gs => gs.Decay.MinThreshold, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + Migrate(); + } + + public void Migrate() + { + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Waifu.Items = c.Waifu.Items.Concat(_antiGiftSeed).ToList(); + c.Version = 2; + }); + } + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + c.VoteReward = 100; + }); + } + + if (data.Version < 5) + { + ModifyConfig(c => + { + c.Version = 5; + }); + } + + if (data.Version < 6) + { + ModifyConfig(c => + { + c.Version = 6; + }); + } + + if (data.Version < 7) + { + ModifyConfig(c => + { + c.Version = 7; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/GamblingService.cs b/src/EllieBot/Modules/Gambling/GamblingService.cs new file mode 100644 index 0000000..a324437 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/GamblingService.cs @@ -0,0 +1,220 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Connect4; + +namespace EllieBot.Modules.Gambling.Services; + +public class GamblingService : IEService, IReadyExecutor +{ + public ConcurrentDictionary<(ulong, ulong), RollDuelGame> Duels { get; } = new(); + public ConcurrentDictionary Connect4Games { get; } = new(); + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCache _cache; + private readonly GamblingConfigService _gss; + + private static readonly TypedKey _curDecayKey = new("currency:last_decay"); + + public GamblingService( + DbService db, + DiscordSocketClient client, + IBotCache cache, + GamblingConfigService gss) + { + _db = db; + _client = client; + _cache = cache; + _gss = gss; + } + + public Task OnReadyAsync() + => Task.WhenAll(CurrencyDecayLoopAsync(), TransactionClearLoopAsync()); + + private async Task TransactionClearLoopAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var lifetime = _gss.Data.Currency.TransactionsLifetime; + if (lifetime <= 0) + continue; + + var now = DateTime.UtcNow; + var days = TimeSpan.FromDays(lifetime); + await using var uow = _db.GetDbContext(); + await uow.Set() + .DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days); + } + catch (Exception ex) + { + Log.Warning(ex, + "An unexpected error occurred in transactions cleanup loop: {ErrorMessage}", + ex.Message); + } + } + } + + private async Task CurrencyDecayLoopAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var config = _gss.Data; + var maxDecay = config.Decay.MaxDecay; + if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0) + continue; + + var now = DateTime.UtcNow; + + await using var uow = _db.GetDbContext(); + var result = await _cache.GetAsync(_curDecayKey); + + if (result.TryPickT0(out var bin, out _) + && (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval))) + { + continue; + } + + Log.Information(""" + --- Decaying users' currency --- + | decay: {ConfigDecayPercent}% + | max: {MaxDecay} + | threshold: {DecayMinTreshold} + """, + config.Decay.Percent * 100, + maxDecay, + config.Decay.MinThreshold); + + if (maxDecay == 0) + maxDecay = int.MaxValue; + + var decay = (double)config.Decay.Percent; + await uow.Set() + .Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id) + .UpdateAsync(old => new() + { + CurrencyAmount = + maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5) + ? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5)) + : old.CurrencyAmount - maxDecay + }); + + await uow.SaveChangesAsync(); + + await _cache.AddAsync(_curDecayKey, now.ToBinary()); + } + catch (Exception ex) + { + Log.Warning(ex, + "An unexpected error occurred in currency decay loop: {ErrorMessage}", + ex.Message); + } + } + } + + private static readonly TypedKey _ecoKey = new("ellie:economy"); + + public async Task GetEconomyAsync() + { + var data = await _cache.GetOrAddAsync(_ecoKey, + async () => + { + await using var uow = _db.GetDbContext(); + var cash = uow.Set().GetTotalCurrency(); + var onePercent = uow.Set().GetTopOnePercentCurrency(_client.CurrentUser.Id); + decimal planted = uow.Set().AsQueryable().Sum(x => x.Amount); + var waifus = uow.Set().GetTotalValue(); + var bot = await uow.Set().GetUserCurrencyAsync(_client.CurrentUser.Id); + decimal bank = await uow.GetTable() + .SumAsyncLinqToDB(x => x.Balance); + + var result = new EconomyResult + { + Cash = cash, + Planted = planted, + Bot = bot, + Waifus = waifus, + OnePercent = onePercent, + Bank = bank + }; + + return result; + }, + TimeSpan.FromMinutes(3)); + + return data; + } + + + private static readonly SemaphoreSlim _timelyLock = new(1, 1); + + private static TypedKey> _timelyKey + = new("timely:claims"); + + public async Task ClaimTimelyAsync(ulong userId, int period) + { + if (period == 0) + return null; + + await _timelyLock.WaitAsync(); + try + { + // get the dictionary from the cache or get a new one + var dict = (await _cache.GetOrAddAsync(_timelyKey, + () => Task.FromResult(new Dictionary())))!; + + var now = DateTime.UtcNow; + var nowB = now.ToBinary(); + + // try to get users last claim + if (!dict.TryGetValue(userId, out var lastB)) + lastB = dict[userId] = now.ToBinary(); + + var diff = now - DateTime.FromBinary(lastB); + + // if its now, or too long ago => success + if (lastB == nowB || diff > period.Hours()) + { + // update the cache + dict[userId] = nowB; + await _cache.AddAsync(_timelyKey, dict); + + return null; + } + else + { + // otherwise return the remaining time + return period.Hours() - diff; + } + } + finally + { + _timelyLock.Release(); + } + } + + public bool UserHasTimelyReminder(ulong userId) + { + var db = _db.GetDbContext(); + return db.GetTable().Any(x => x.UserId == userId + && x.Type == ReminderType.Timely); + } + + public async Task RemoveAllTimelyClaimsAsync() + => await _cache.RemoveAsync(_timelyKey); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs b/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs new file mode 100644 index 0000000..25cbb73 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs @@ -0,0 +1,68 @@ +#nullable disable +using EllieBot.Modules.Gambling.Services; +using System.Numerics; + +namespace EllieBot.Modules.Gambling.Common; + +public abstract class GamblingModule : EllieModule +{ + protected GamblingConfig Config + => _lazyConfig.Value; + + protected string CurrencySign + => Config.Currency.Sign; + + protected string CurrencyName + => Config.Currency.Name; + + private readonly Lazy _lazyConfig; + + protected GamblingModule(GamblingConfigService gambService) + => _lazyConfig = new(() => gambService.Data); + + private async Task InternalCheckBet(long amount) + { + if (amount < 1) + return false; + + if (amount < Config.MinBet) + { + await Response().Error(strs.min_bet_limit(Format.Bold(Config.MinBet.ToString()) + CurrencySign)).SendAsync(); + return false; + } + + if (Config.MaxBet > 0 && amount > Config.MaxBet) + { + await Response().Error(strs.max_bet_limit(Format.Bold(Config.MaxBet.ToString()) + CurrencySign)).SendAsync(); + return false; + } + + return true; + } + + protected string N(T cur) + where T : INumber + => CurrencyHelper.N(cur, Culture, CurrencySign); + + protected Task CheckBetMandatory(long amount) + { + if (amount < 1) + return Task.FromResult(false); + return InternalCheckBet(amount); + } + + protected Task CheckBetOptional(long amount) + { + if (amount == 0) + return Task.FromResult(true); + return InternalCheckBet(amount); + } +} + +public abstract class GamblingSubmodule : GamblingModule +{ + protected GamblingSubmodule(GamblingConfigService gamblingConfService) + : base(gamblingConfService) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/InputRpsPick.cs b/src/EllieBot/Modules/Gambling/InputRpsPick.cs new file mode 100644 index 0000000..d0c76f0 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/InputRpsPick.cs @@ -0,0 +1,3 @@ +#nullable disable +namespace EllieBot.Modules.Gambling; + diff --git a/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs b/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs new file mode 100644 index 0000000..67994c8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs @@ -0,0 +1,114 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class PlantPickCommands : GamblingSubmodule + { + private readonly ILogCommandService _logService; + + public PlantPickCommands(ILogCommandService logService, GamblingConfigService gss) + : base(gss) + => _logService = logService; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Pick(string pass = null) + { + if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric()) + return; + + var picked = await _service.PickAsync(ctx.Guild.Id, (ITextChannel)ctx.Channel, ctx.User.Id, pass); + + if (picked > 0) + { + var msg = await Response().NoReply().Confirm(strs.picked(N(picked), ctx.User)).SendAsync(); + msg.DeleteAfter(10); + } + + if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages) + { + try + { + _logService.AddDeleteIgnore(ctx.Message.Id); + await ctx.Message.DeleteAsync(); + } + catch { } + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Plant([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, string pass = null) + { + if (amount < 1) + return; + + if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric()) + return; + + if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages) + { + _logService.AddDeleteIgnore(ctx.Message.Id); + await ctx.Message.DeleteAsync(); + } + + var success = await _service.PlantAsync(ctx.Guild.Id, + ctx.Channel, + ctx.User.Id, + ctx.User.ToString(), + amount, + pass); + + if (!success) + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] +#if GLOBAL_ELLIE + [OwnerOnly] +#endif + public async Task GenCurrency() + { + var enabled = _service.ToggleCurrencyGeneration(ctx.Guild.Id, ctx.Channel.Id); + if (enabled) + await Response().Confirm(strs.curgen_enabled).SendAsync(); + else + await Response().Confirm(strs.curgen_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [OwnerOnly] + public Task GenCurList(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + var enabledIn = _service.GetAllGeneratingChannels(); + + return Response() + .Paginated() + .Items(enabledIn.ToList()) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + if (!items.Any()) + return _sender.CreateEmbed().WithErrorColor().WithDescription("-"); + + return items.Aggregate(_sender.CreateEmbed().WithOkColor(), + (eb, i) => eb.AddField(i.GuildId.ToString(), i.ChannelId)); + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs b/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs new file mode 100644 index 0000000..6b50a1e --- /dev/null +++ b/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs @@ -0,0 +1,385 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Color = SixLabors.ImageSharp.Color; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling.Services; + +public class PlantPickService : IEService, IExecNoCommand +{ + //channelId/last generation + public ConcurrentDictionary LastGenerations { get; } = new(); + private readonly DbService _db; + private readonly IBotStrings _strings; + private readonly IImageCache _images; + private readonly FontProvider _fonts; + private readonly ICurrencyService _cs; + private readonly CommandHandler _cmdHandler; + private readonly EllieRandom _rng; + private readonly DiscordSocketClient _client; + private readonly GamblingConfigService _gss; + + private readonly ConcurrentHashSet _generationChannels; + private readonly SemaphoreSlim _pickLock = new(1, 1); + + public PlantPickService( + DbService db, + CommandHandler cmd, + IBotStrings strings, + IImageCache images, + FontProvider fonts, + ICurrencyService cs, + CommandHandler cmdHandler, + DiscordSocketClient client, + GamblingConfigService gss) + { + _db = db; + _strings = strings; + _images = images; + _fonts = fonts; + _cs = cs; + _cmdHandler = cmdHandler; + _rng = new(); + _client = client; + _gss = gss; + + using var uow = db.GetDbContext(); + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set() + .AsQueryable() + .Include(x => x.GenerateCurrencyChannelIds) + .Where(x => guildIds.Contains(x.GuildId)) + .ToList(); + + _generationChannels = new(configs.SelectMany(c => c.GenerateCurrencyChannelIds.Select(obj => obj.ChannelId))); + } + + public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + => PotentialFlowerGeneration(msg); + + private string GetText(ulong gid, LocStr str) + => _strings.GetText(str, gid); + + public bool ToggleCurrencyGeneration(ulong gid, ulong cid) + { + bool enabled; + using var uow = _db.GetDbContext(); + var guildConfig = uow.GuildConfigsForId(gid, set => set.Include(gc => gc.GenerateCurrencyChannelIds)); + + var toAdd = new GCChannelId + { + ChannelId = cid + }; + if (!guildConfig.GenerateCurrencyChannelIds.Contains(toAdd)) + { + guildConfig.GenerateCurrencyChannelIds.Add(toAdd); + _generationChannels.Add(cid); + enabled = true; + } + else + { + var toDelete = guildConfig.GenerateCurrencyChannelIds.FirstOrDefault(x => x.Equals(toAdd)); + if (toDelete is not null) + uow.Remove(toDelete); + _generationChannels.TryRemove(cid); + enabled = false; + } + + uow.SaveChanges(); + return enabled; + } + + public IEnumerable GetAllGeneratingChannels() + { + using var uow = _db.GetDbContext(); + var chs = uow.Set().GetGeneratingChannels(); + return chs; + } + + /// + /// Get a random currency image stream, with an optional password sticked onto it. + /// + /// Optional password to add to top left corner. + /// Extension of the file, defaults to png + /// Stream of the currency image + public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass) + { + var curImg = await _images.GetCurrencyImageAsync(); + + if (string.IsNullOrWhiteSpace(pass)) + { + // determine the extension + using var load = _ = Image.Load(curImg, out var format); + + // return the image + return (curImg.ToStream(), format.FileExtensions.FirstOrDefault() ?? "png"); + } + + // get the image stream and extension + return AddPassword(curImg, pass); + } + + /// + /// Add a password to the image. + /// + /// Image to add password to. + /// Password to add to top left corner. + /// Image with the password in the top left corner. + private (Stream, string) AddPassword(byte[] curImg, string pass) + { + // draw lower, it looks better + pass = pass.TrimTo(10, true).ToLowerInvariant(); + using var img = Image.Load(curImg, out var format); + // choose font size based on the image height, so that it's visible + var font = _fonts.NotoSans.CreateFont(img.Height / 12.0f, FontStyle.Bold); + img.Mutate(x => + { + // measure the size of the text to be drawing + var size = TextMeasurer.Measure(pass, new TextOptions(font) + { + Origin = new PointF(0, 0) + }); + + // fill the background with black, add 5 pixels on each side to make it look better + x.FillPolygon(Color.ParseHex("00000080"), + new PointF(0, 0), + new PointF(size.Width + 5, 0), + new PointF(size.Width + 5, size.Height + 10), + new PointF(0, size.Height + 10)); + + // draw the password over the background + x.DrawText(pass, font, Color.White, new(0, 0)); + }); + // return image as a stream for easy sending + return (img.ToStream(format), format.FileExtensions.FirstOrDefault() ?? "png"); + } + + private Task PotentialFlowerGeneration(IUserMessage imsg) + { + if (imsg is not SocketUserMessage msg || msg.Author.IsBot) + return Task.CompletedTask; + + if (imsg.Channel is not ITextChannel channel) + return Task.CompletedTask; + + if (!_generationChannels.Contains(channel.Id)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + var config = _gss.Data; + var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary()); + var rng = new EllieRandom(); + + if (DateTime.UtcNow - TimeSpan.FromSeconds(config.Generation.GenCooldown) + < DateTime.FromBinary(lastGeneration)) //recently generated in this channel, don't generate again + return; + + var num = rng.Next(1, 101) + (config.Generation.Chance * 100); + if (num > 100 && LastGenerations.TryUpdate(channel.Id, DateTime.UtcNow.ToBinary(), lastGeneration)) + { + var dropAmount = config.Generation.MinAmount; + var dropAmountMax = config.Generation.MaxAmount; + + if (dropAmountMax > dropAmount) + dropAmount = new EllieRandom().Next(dropAmount, dropAmountMax + 1); + + if (dropAmount > 0) + { + var prefix = _cmdHandler.GetPrefix(channel.Guild.Id); + var toSend = dropAmount == 1 + ? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign)) + + " " + + GetText(channel.GuildId, strs.pick_sn(prefix)) + : GetText(channel.GuildId, strs.curgen_pl(dropAmount, config.Currency.Sign)) + + " " + + GetText(channel.GuildId, strs.pick_pl(prefix)); + + var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null; + + IUserMessage sent; + var (stream, ext) = await GetRandomCurrencyImageAsync(pw); + + await using (stream) + sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend); + + await AddPlantToDatabase(channel.GuildId, + channel.Id, + _client.CurrentUser.Id, + sent.Id, + dropAmount, + pw); + } + } + } + catch + { + } + }); + return Task.CompletedTask; + } + + /// + /// Generate a hexadecimal string from 1000 to ffff. + /// + /// A hexadecimal string from 1000 to ffff + private string GenerateCurrencyPassword() + { + // generate a number from 1000 to ffff + var num = _rng.Next(4096, 65536); + // convert it to hexadecimal + return num.ToString("x4"); + } + + public async Task PickAsync( + ulong gid, + ITextChannel ch, + ulong uid, + string pass) + { + await _pickLock.WaitAsync(); + try + { + long amount; + ulong[] ids; + await using (var uow = _db.GetDbContext()) + { + // this method will sum all plants with that password, + // remove them, and get messageids of the removed plants + + pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant(); + // gets all plants in this channel with the same password + var entries = uow.Set().AsQueryable() + .Where(x => x.ChannelId == ch.Id && pass == x.Password) + .ToList(); + // sum how much currency that is, and get all of the message ids (so that i can delete them) + amount = entries.Sum(x => x.Amount); + ids = entries.Select(x => x.MessageId).ToArray(); + // remove them from the database + uow.RemoveRange(entries); + + + if (amount > 0) + // give the picked currency to the user + await _cs.AddAsync(uid, amount, new("currency", "collect")); + await uow.SaveChangesAsync(); + } + + try + { + // delete all of the plant messages which have just been picked + _ = ch.DeleteMessagesAsync(ids); + } + catch { } + + // return the amount of currency the user picked + return amount; + } + finally + { + _pickLock.Release(); + } + } + + public async Task SendPlantMessageAsync( + ulong gid, + IMessageChannel ch, + string user, + long amount, + string pass) + { + try + { + // get the text + var prefix = _cmdHandler.GetPrefix(gid); + var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + _gss.Data.Currency.Sign)); + + if (amount > 1) + msgToSend += " " + GetText(gid, strs.pick_pl(prefix)); + else + msgToSend += " " + GetText(gid, strs.pick_sn(prefix)); + + //get the image + var (stream, ext) = await GetRandomCurrencyImageAsync(pass); + // send it + await using (stream) + { + var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend); + // return sent message's id (in order to be able to delete it when it's picked) + return msg.Id; + } + } + catch (Exception ex) + { + // if sending fails, return null as message id + Log.Warning(ex, "Sending plant message failed: {Message}", ex.Message); + return null; + } + } + + public async Task PlantAsync( + ulong gid, + IMessageChannel ch, + ulong uid, + string user, + long amount, + string pass) + { + // normalize it - no more than 10 chars, uppercase + pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant(); + // has to be either null or alphanumeric + if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric()) + return false; + + // remove currency from the user who's planting + if (await _cs.RemoveAsync(uid, amount, new("put/collect", "put"))) + { + // try to send the message with the currency image + var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass); + if (msgId is null) + { + // if it fails it will return null, if it returns null, refund + await _cs.AddAsync(uid, amount, new("put/collect", "refund")); + return false; + } + + // if it doesn't fail, put the plant in the database for other people to pick + await AddPlantToDatabase(gid, ch.Id, uid, msgId.Value, amount, pass); + return true; + } + + // if user doesn't have enough currency, fail + return false; + } + + private async Task AddPlantToDatabase( + ulong gid, + ulong cid, + ulong uid, + ulong mid, + long amount, + string pass) + { + await using var uow = _db.GetDbContext(); + uow.Set().Add(new() + { + Amount = amount, + GuildId = gid, + ChannelId = cid, + Password = pass, + UserId = uid, + MessageId = mid + }); + await uow.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleCommands.cs b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleCommands.cs new file mode 100644 index 0000000..513aa59 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleCommands.cs @@ -0,0 +1,61 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + public partial class CurrencyRaffleCommands : GamblingSubmodule + { + public enum Mixed { Mixed } + + public CurrencyRaffleCommands(GamblingConfigService gamblingConfService) + : base(gamblingConfService) + { + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task RaffleCur(Mixed _, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + => RaffleCur(amount, true); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task RaffleCur([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, bool mixed = false) + { + if (!await CheckBetMandatory(amount)) + return; + + async Task OnEnded(IUser arg, long won) + { + await Response() + .Confirm(GetText(strs.rafflecur_ended(CurrencyName, + Format.Bold(arg.ToString()), + won + CurrencySign))) + .SendAsync(); + } + + var res = await _service.JoinOrCreateGame(ctx.Channel.Id, ctx.User, amount, mixed, OnEnded); + + if (res.Item1 is not null) + { + await Response() + .Confirm(GetText(strs.rafflecur(res.Item1.GameType.ToString())), + string.Join("\n", res.Item1.Users.Select(x => $"{x.DiscordUser} ({N(x.Amount)})")), + footer: GetText(strs.rafflecur_joined(ctx.User.ToString()))) + .SendAsync(); + } + else + { + if (res.Item2 == CurrencyRaffleService.JoinErrorType.AlreadyJoinedOrInvalidAmount) + await Response().Error(strs.rafflecur_already_joined).SendAsync(); + else if (res.Item2 == CurrencyRaffleService.JoinErrorType.NotEnoughCurrency) + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs new file mode 100644 index 0000000..d6f5770 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs @@ -0,0 +1,69 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common; + +public class CurrencyRaffleGame +{ + public enum Type + { + Mixed, + Normal + } + + public IEnumerable Users + => _users; + + public Type GameType { get; } + + private readonly HashSet _users = new(); + + public CurrencyRaffleGame(Type type) + => GameType = type; + + public bool AddUser(IUser usr, long amount) + { + // if game type is normal, and someone already joined the game + // (that's the user who created it) + if (GameType == Type.Normal && _users.Count > 0 && _users.First().Amount != amount) + return false; + + if (!_users.Add(new() + { + DiscordUser = usr, + Amount = amount + })) + return false; + + return true; + } + + public User GetWinner() + { + var rng = new EllieRandom(); + if (GameType == Type.Mixed) + { + var num = rng.NextLong(0L, Users.Sum(x => x.Amount)); + var sum = 0L; + foreach (var u in Users) + { + sum += u.Amount; + if (sum > num) + return u; + } + } + + var usrs = _users.ToArray(); + return usrs[rng.Next(0, usrs.Length)]; + } + + public class User + { + public IUser DiscordUser { get; set; } + public long Amount { get; set; } + + public override int GetHashCode() + => DiscordUser.GetHashCode(); + + public override bool Equals(object obj) + => obj is User u ? u.DiscordUser == DiscordUser : false; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleService.cs b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleService.cs new file mode 100644 index 0000000..743549e --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleService.cs @@ -0,0 +1,81 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; + +namespace EllieBot.Modules.Gambling.Services; + +public class CurrencyRaffleService : IEService +{ + public enum JoinErrorType + { + NotEnoughCurrency, + AlreadyJoinedOrInvalidAmount + } + + public Dictionary Games { get; } = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly ICurrencyService _cs; + + public CurrencyRaffleService(ICurrencyService cs) + => _cs = cs; + + public async Task<(CurrencyRaffleGame, JoinErrorType?)> JoinOrCreateGame( + ulong channelId, + IUser user, + long amount, + bool mixed, + Func onEnded) + { + await _locker.WaitAsync(); + try + { + var newGame = false; + if (!Games.TryGetValue(channelId, out var crg)) + { + newGame = true; + crg = new(mixed ? CurrencyRaffleGame.Type.Mixed : CurrencyRaffleGame.Type.Normal); + Games.Add(channelId, crg); + } + + //remove money, and stop the game if this + // user created it and doesn't have the money + if (!await _cs.RemoveAsync(user.Id, amount, new("raffle", "join"))) + { + if (newGame) + Games.Remove(channelId); + return (null, JoinErrorType.NotEnoughCurrency); + } + + if (!crg.AddUser(user, amount)) + { + await _cs.AddAsync(user.Id, amount, new("raffle", "refund")); + return (null, JoinErrorType.AlreadyJoinedOrInvalidAmount); + } + + if (newGame) + { + _ = Task.Run(async () => + { + await Task.Delay(60000); + await _locker.WaitAsync(); + try + { + var winner = crg.GetWinner(); + var won = crg.Users.Sum(x => x.Amount); + + await _cs.AddAsync(winner.DiscordUser.Id, won, new("raffle", "win")); + Games.Remove(channelId, out _); + _ = onEnded(winner.DiscordUser, won); + } + catch { } + finally { _locker.Release(); } + }); + } + + return (crg, null); + } + finally + { + _locker.Release(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Shop/IShopService.cs b/src/EllieBot/Modules/Gambling/Shop/IShopService.cs new file mode 100644 index 0000000..f8d19f8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Shop/IShopService.cs @@ -0,0 +1,46 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Services; + +public interface IShopService +{ + /// + /// Changes the price of a shop item + /// + /// Id of the guild in which the shop is + /// Index of the item + /// New item price + /// Success status + Task ChangeEntryPriceAsync(ulong guildId, int index, int newPrice); + + /// + /// Changes the name of a shop item + /// + /// Id of the guild in which the shop is + /// Index of the item + /// New item name + /// Success status + Task ChangeEntryNameAsync(ulong guildId, int index, string newName); + + /// + /// Swaps indexes of 2 items in the shop + /// + /// Id of the guild in which the shop is + /// First entry's index + /// Second entry's index + /// Whether swap was successful + Task SwapEntriesAsync(ulong guildId, int index1, int index2); + + /// + /// Swaps indexes of 2 items in the shop + /// + /// Id of the guild in which the shop is + /// Current index of the entry to move + /// Destination index of the entry + /// Whether swap was successful + Task MoveEntryAsync(ulong guildId, int fromIndex, int toIndex); + + Task SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId); + Task AddShopCommandAsync(ulong guildId, ulong userId, int price, string command); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs b/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs new file mode 100644 index 0000000..8b5230b --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs @@ -0,0 +1,590 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Db.Models; +using EllieBot.Modules.Administration; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class ShopCommands : GamblingSubmodule + { + public enum List + { + List + } + + public enum Role + { + Role + } + + public enum Command + { + Command, + Cmd + } + + private readonly DbService _db; + private readonly ICurrencyService _cs; + + public ShopCommands(DbService db, ICurrencyService cs, GamblingConfigService gamblingConf) + : base(gamblingConf) + { + _db = db; + _cs = cs; + } + + private Task ShopInternalAsync(int page = 0) + { + if (page < 0) + throw new ArgumentOutOfRangeException(nameof(page)); + + using var uow = _db.GetDbContext(); + var entries = uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items)) + .ShopEntries.ToIndexed(); + + return Response() + .Paginated() + .Items(entries.ToList()) + .PageSize(9) + .CurrentPage(page) + .Page((items, curPage) => + { + if (!items.Any()) + return _sender.CreateEmbed().WithErrorColor().WithDescription(GetText(strs.shop_none)); + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.shop)); + + for (var i = 0; i < items.Count; i++) + { + var entry = items[i]; + embed.AddField($"#{(curPage * 9) + i + 1} - {N(entry.Price)}", + EntryToString(entry), + true); + } + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Shop(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ShopInternalAsync(page); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Buy(int index) + { + index -= 1; + if (index < 0) + return; + ShopEntry entry; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items)); + var entries = new IndexedCollection(config.ShopEntries); + entry = entries.ElementAtOrDefault(index); + uow.SaveChanges(); + } + + if (entry is null) + { + await Response().Error(strs.shop_item_not_found).SendAsync(); + return; + } + + if (entry.RoleRequirement is ulong reqRoleId) + { + var role = ctx.Guild.GetRole(reqRoleId); + if (role is null) + { + await Response().Error(strs.shop_item_req_role_not_found).SendAsync(); + return; + } + + var guser = (IGuildUser)ctx.User; + if (!guser.RoleIds.Contains(reqRoleId)) + { + await Response() + .Error(strs.shop_item_req_role_unfulfilled(Format.Bold(role.ToString()))) + .SendAsync(); + return; + } + } + + if (entry.Type == ShopEntryType.Role) + { + var guser = (IGuildUser)ctx.User; + var role = ctx.Guild.GetRole(entry.RoleId); + + if (role is null) + { + await Response().Error(strs.shop_role_not_found).SendAsync(); + return; + } + + if (guser.RoleIds.Any(id => id == role.Id)) + { + await Response().Error(strs.shop_role_already_bought).SendAsync(); + return; + } + + if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString()))) + { + try + { + await guser.AddRoleAsync(role); + } + catch (Exception ex) + { + Log.Warning(ex, "Error adding shop role"); + await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund")); + await Response().Error(strs.shop_role_purchase_error).SendAsync(); + return; + } + + var profit = GetProfitAmount(entry.Price); + await _cs.AddAsync(entry.AuthorId, profit, new("shop", "sell", $"Shop sell item - {entry.Type}")); + await _cs.AddAsync(ctx.Client.CurrentUser.Id, entry.Price - profit, new("shop", "cut")); + await Response().Confirm(strs.shop_role_purchase(Format.Bold(role.Name))).SendAsync(); + return; + } + + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + else if (entry.Type == ShopEntryType.List) + { + if (entry.Items.Count == 0) + { + await Response().Error(strs.out_of_stock).SendAsync(); + return; + } + + var item = entry.Items.ToArray()[new EllieRandom().Next(0, entry.Items.Count)]; + + if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString()))) + { + await using (var uow = _db.GetDbContext()) + { + uow.Set().Remove(item); + await uow.SaveChangesAsync(); + } + + try + { + await Response() + .User(ctx.User) + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.shop_purchase(ctx.Guild.Name))) + .AddField(GetText(strs.item), item.Text) + .AddField(GetText(strs.price), entry.Price.ToString(), true) + .AddField(GetText(strs.name), entry.Name, true)) + .SendAsync(); + + await _cs.AddAsync(entry.AuthorId, + GetProfitAmount(entry.Price), + new("shop", "sell", entry.Name)); + } + catch + { + await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund", entry.Name)); + await using (var uow = _db.GetDbContext()) + { + var entries = new IndexedCollection(uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries); + entry = entries.ElementAtOrDefault(index); + if (entry is not null) + { + if (entry.Items.Add(item)) + uow.SaveChanges(); + } + } + + await Response().Error(strs.shop_buy_error).SendAsync(); + return; + } + + await Response().Confirm(strs.shop_item_purchase).SendAsync(); + } + else + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + else if (entry.Type == ShopEntryType.Command) + { + var guild = ctx.Guild as SocketGuild; + var channel = ctx.Channel as ISocketMessageChannel; + var msg = ctx.Message as SocketUserMessage; + var user = await ctx.Guild.GetUserAsync(entry.AuthorId); + + if (guild is null || channel is null || msg is null || user is null) + { + await Response().Error(strs.shop_command_invalid_context).SendAsync(); + return; + } + + if (!await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString()))) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + else + { + var cmd = entry.Command.Replace("%you%", ctx.User.Id.ToString()); + var eb = _sender.CreateEmbed() + .WithPendingColor() + .WithTitle("Executing shop command") + .WithDescription(cmd); + + var msgTask = Response().Embed(eb).SendAsync(); + + await _cs.AddAsync(entry.AuthorId, + GetProfitAmount(entry.Price), + new("shop", "sell", entry.Name)); + + await _cmdHandler.TryRunCommand(guild, + channel, + new DoAsUserMessage( + msg, + user, + cmd + )); + + try + { + var pendingMsg = await msgTask; + await pendingMsg.EditAsync( + SmartEmbedText.FromEmbed(eb + .WithOkColor() + .WithTitle("Shop command executed") + .Build())); + } + catch + { + } + } + } + } + + private long GetProfitAmount(int price) + => (int)Math.Ceiling((1.0m - Config.BotCuts.ShopSaleCut) * price); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ShopAdd(Command _, int price, [Leftover] string command) + { + if (price < 1) + return; + + + var entry = await _service.AddShopCommandAsync(ctx.Guild.Id, ctx.User.Id, price, command); + + await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ShopAdd(Role _, int price, [Leftover] IRole role) + { + if (price < 1) + return; + + var entry = new ShopEntry + { + Name = "-", + Price = price, + Type = ShopEntryType.Role, + AuthorId = ctx.User.Id, + RoleId = role.Id, + RoleName = role.Name + }; + await using (var uow = _db.GetDbContext()) + { + var entries = new IndexedCollection(uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries) + { + entry + }; + uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries; + uow.SaveChanges(); + } + + await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopAdd(List _, int price, [Leftover] string name) + { + if (price < 1) + return; + + var entry = new ShopEntry + { + Name = name.TrimTo(100), + Price = price, + Type = ShopEntryType.List, + AuthorId = ctx.User.Id, + Items = new() + }; + await using (var uow = _db.GetDbContext()) + { + var entries = new IndexedCollection(uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries) + { + entry + }; + uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries; + uow.SaveChanges(); + } + + await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopListAdd(int index, [Leftover] string itemText) + { + index -= 1; + if (index < 0) + return; + var item = new ShopEntryItem + { + Text = itemText + }; + ShopEntry entry; + var rightType = false; + var added = false; + await using (var uow = _db.GetDbContext()) + { + var entries = new IndexedCollection(uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries); + entry = entries.ElementAtOrDefault(index); + if (entry is not null && (rightType = entry.Type == ShopEntryType.List)) + { + if (entry.Items.Add(item)) + { + added = true; + uow.SaveChanges(); + } + } + } + + if (entry is null) + await Response().Error(strs.shop_item_not_found).SendAsync(); + else if (!rightType) + await Response().Error(strs.shop_item_wrong_type).SendAsync(); + else if (added == false) + await Response().Error(strs.shop_list_item_not_unique).SendAsync(); + else + await Response().Confirm(strs.shop_list_item_added).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopRemove(int index) + { + index -= 1; + if (index < 0) + return; + ShopEntry removed; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items)); + + var entries = new IndexedCollection(config.ShopEntries); + removed = entries.ElementAtOrDefault(index); + if (removed is not null) + { + uow.RemoveRange(removed.Items); + uow.Remove(removed); + uow.SaveChanges(); + } + } + + if (removed is null) + await Response().Error(strs.shop_item_not_found).SendAsync(); + else + await Response().Embed(EntryToEmbed(removed).WithTitle(GetText(strs.shop_item_rm))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopChangePrice(int index, int price) + { + if (--index < 0 || price <= 0) + return; + + var succ = await _service.ChangeEntryPriceAsync(ctx.Guild.Id, index, price); + if (succ) + { + await ShopInternalAsync(index / 9); + await ctx.OkAsync(); + } + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopChangeName(int index, [Leftover] string newName) + { + if (--index < 0 || string.IsNullOrWhiteSpace(newName)) + return; + + var succ = await _service.ChangeEntryNameAsync(ctx.Guild.Id, index, newName); + if (succ) + { + await ShopInternalAsync(index / 9); + await ctx.OkAsync(); + } + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopSwap(int index1, int index2) + { + if (--index1 < 0 || --index2 < 0 || index1 == index2) + return; + + var succ = await _service.SwapEntriesAsync(ctx.Guild.Id, index1, index2); + if (succ) + { + await ShopInternalAsync(index1 / 9); + await ctx.OkAsync(); + } + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopMove(int fromIndex, int toIndex) + { + if (--fromIndex < 0 || --toIndex < 0 || fromIndex == toIndex) + return; + + var succ = await _service.MoveEntryAsync(ctx.Guild.Id, fromIndex, toIndex); + if (succ) + { + await ShopInternalAsync(toIndex / 9); + await ctx.OkAsync(); + } + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopReq(int itemIndex, [Leftover] IRole role = null) + { + if (--itemIndex < 0) + return; + + var succ = await _service.SetItemRoleRequirementAsync(ctx.Guild.Id, itemIndex, role?.Id); + if (!succ) + { + await Response().Error(strs.shop_item_not_found).SendAsync(); + return; + } + + if (role is null) + await Response().Confirm(strs.shop_item_role_no_req(itemIndex)).SendAsync(); + else + await Response().Confirm(strs.shop_item_role_req(itemIndex + 1, role)).SendAsync(); + } + + public EmbedBuilder EntryToEmbed(ShopEntry entry) + { + var embed = _sender.CreateEmbed().WithOkColor(); + + if (entry.Type == ShopEntryType.Role) + { + return embed + .AddField(GetText(strs.name), + GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name + ?? "MISSING_ROLE"))), + true) + .AddField(GetText(strs.price), N(entry.Price), true) + .AddField(GetText(strs.type), entry.Type.ToString(), true); + } + + if (entry.Type == ShopEntryType.List) + { + return embed.AddField(GetText(strs.name), entry.Name, true) + .AddField(GetText(strs.price), N(entry.Price), true) + .AddField(GetText(strs.type), GetText(strs.random_unique_item), true); + } + + else if (entry.Type == ShopEntryType.Command) + { + return embed + .AddField(GetText(strs.name), Format.Code(entry.Command), true) + .AddField(GetText(strs.price), N(entry.Price), true) + .AddField(GetText(strs.type), entry.Type.ToString(), true); + } + + //else if (entry.Type == ShopEntryType.Infinite_List) + // return embed.AddField(GetText(strs.name), GetText(strs.shop_role(Format.Bold(entry.RoleName)), true)) + // .AddField(GetText(strs.price), entry.Price.ToString(), true) + // .AddField(GetText(strs.type), entry.Type.ToString(), true); + return null; + } + + public string EntryToString(ShopEntry entry) + { + var prepend = string.Empty; + if (entry.RoleRequirement is not null) + prepend = Format.Italics(GetText(strs.shop_item_requires_role($"<@&{entry.RoleRequirement}>"))) + + Environment.NewLine; + + if (entry.Type == ShopEntryType.Role) + return prepend + + GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name ?? "MISSING_ROLE"))); + if (entry.Type == ShopEntryType.List) + return prepend + GetText(strs.unique_items_left(entry.Items.Count)) + "\n" + entry.Name; + + if (entry.Type == ShopEntryType.Command) + return prepend + Format.Code(entry.Command); + return prepend; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Shop/ShopService.cs b/src/EllieBot/Modules/Gambling/Shop/ShopService.cs new file mode 100644 index 0000000..dfe944a --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Shop/ShopService.cs @@ -0,0 +1,127 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Services; + +public class ShopService : IShopService, IEService +{ + private readonly DbService _db; + + public ShopService(DbService db) + => _db = db; + + private IndexedCollection GetEntriesInternal(DbContext uow, ulong guildId) + => uow.GuildConfigsForId(guildId, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries.ToIndexed(); + + public async Task ChangeEntryPriceAsync(ulong guildId, int index, int newPrice) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(newPrice); + + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (index >= entries.Count) + return false; + + entries[index].Price = newPrice; + await uow.SaveChangesAsync(); + return true; + } + + public async Task ChangeEntryNameAsync(ulong guildId, int index, string newName) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + + if (string.IsNullOrWhiteSpace(newName)) + throw new ArgumentNullException(nameof(newName)); + + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (index >= entries.Count) + return false; + + entries[index].Name = newName.TrimTo(100); + await uow.SaveChangesAsync(); + return true; + } + + public async Task SwapEntriesAsync(ulong guildId, int index1, int index2) + { + ArgumentOutOfRangeException.ThrowIfNegative(index1); + ArgumentOutOfRangeException.ThrowIfNegative(index2); + + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (index1 >= entries.Count || index2 >= entries.Count || index1 == index2) + return false; + + entries[index1].Index = index2; + entries[index2].Index = index1; + + await uow.SaveChangesAsync(); + return true; + } + + public async Task MoveEntryAsync(ulong guildId, int fromIndex, int toIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(fromIndex); + ArgumentOutOfRangeException.ThrowIfNegative(toIndex); + + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (fromIndex >= entries.Count || toIndex >= entries.Count || fromIndex == toIndex) + return false; + + var entry = entries[fromIndex]; + entries.RemoveAt(fromIndex); + entries.Insert(toIndex, entry); + + await uow.SaveChangesAsync(); + return true; + } + + public async Task SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId) + { + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (index >= entries.Count) + return false; + + var entry = entries[index]; + + entry.RoleRequirement = roleId; + + await uow.SaveChangesAsync(); + return true; + } + + public async Task AddShopCommandAsync(ulong guildId, ulong userId, int price, string command) + { + await using var uow = _db.GetDbContext(); + + var entries = GetEntriesInternal(uow, guildId); + var entry = new ShopEntry() + { + AuthorId = userId, + Command = command, + Type = ShopEntryType.Command, + Price = price, + }; + entries.Add(entry); + uow.GuildConfigsForId(guildId, set => set).ShopEntries = entries; + + await uow.SaveChangesAsync(); + + return entry; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs b/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs new file mode 100644 index 0000000..238e97e --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs @@ -0,0 +1,230 @@ +#nullable disable warnings +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using EllieBot.Modules.Gambling; +using EllieBot.Common.TypeReaders; +using Color = SixLabors.ImageSharp.Color; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling; + +public enum GamblingError +{ + InsufficientFunds, +} + +public partial class Gambling +{ + [Group] + public partial class SlotCommands : GamblingSubmodule + { + private static decimal totalBet; + private static decimal totalPaidOut; + + private readonly IImageCache _images; + private readonly FontProvider _fonts; + private readonly DbService _db; + private object _slotStatsLock = new(); + + public SlotCommands( + IImageCache images, + FontProvider fonts, + DbService db, + GamblingConfigService gamb) + : base(gamb) + { + _images = images; + _fonts = fonts; + _db = db; + } + + public Task Test() + => Task.CompletedTask; + + [Cmd] + public async Task Slot([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + return; + + // var slotInteraction = CreateSlotInteractionIntenal(amount); + + await ctx.Channel.TriggerTypingAsync(); + + if (await InternalSlotAsync(amount) is not SlotResult result) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var text = GetSlotMessageTextInternal(result); + + using var image = await GenerateSlotImageAsync(amount, result); + await using var imgStream = await image.ToStreamAsync(); + + + var eb = _sender.CreateEmbed() + .WithAuthor(ctx.User) + .WithDescription(Format.Bold(text)) + .WithImageUrl($"attachment://result.png") + .WithOkColor(); + + var bb = new ButtonBuilder(emote: Emoji.Parse("🔁"), customId: "slot:again", label: "Pull Again"); + var inter = _inter.Create(ctx.User.Id, bb, smc => + { + smc.DeferAsync(); + return Slot(amount); + }); + + var msg = await ctx.Channel.SendFileAsync(imgStream, + "result.png", + embed: eb.Build(), + components: inter.CreateComponent() + ); + await inter.RunAsync(msg); + } + + // private SlotInteraction CreateSlotInteractionIntenal(long amount) + // { + // return new SlotInteraction((DiscordSocketClient)ctx.Client, + // ctx.User.Id, + // async (smc) => + // { + // try + // { + // if (await InternalSlotAsync(amount) is not SlotResult result) + // { + // await smc.RespondErrorAsync(_eb, GetText(strs.not_enough(CurrencySign)), true); + // return; + // } + // + // var msg = GetSlotMessageInternal(result); + // + // using var image = await GenerateSlotImageAsync(amount, result); + // await using var imgStream = await image.ToStreamAsync(); + // + // var guid = Guid.NewGuid(); + // var imgName = $"result_{guid}.png"; + // + // var slotInteraction = CreateSlotInteractionIntenal(amount).GetInteraction(); + // + // await smc.Message.ModifyAsync(m => + // { + // m.Content = msg; + // m.Attachments = new[] + // { + // new FileAttachment(imgStream, imgName) + // }; + // m.Components = slotInteraction.CreateComponent(); + // }); + // + // _ = slotInteraction.RunAsync(smc.Message); + // } + // catch (Exception ex) + // { + // Log.Error(ex, "Error pulling slot again"); + // } + // // finally + // // { + // // await Task.Delay(1000); + // // _runningUsers.TryRemove(ctx.User.Id); + // // } + // }); + // } + + private string GetSlotMessageTextInternal(SlotResult result) + { + var multi = result.Multiplier.ToString("0.##"); + var msg = result.WinType switch + { + SlotWinType.SingleJoker => GetText(strs.slot_single(CurrencySign, multi)), + SlotWinType.DoubleJoker => GetText(strs.slot_two(CurrencySign, multi)), + SlotWinType.TrippleNormal => GetText(strs.slot_three(multi)), + SlotWinType.TrippleJoker => GetText(strs.slot_jackpot(multi)), + _ => GetText(strs.better_luck), + }; + return msg; + } + + private async Task InternalSlotAsync(long amount) + { + var maybeResult = await _service.SlotAsync(ctx.User.Id, amount); + + if (!maybeResult.TryPickT0(out var result, out var error)) + { + return null; + } + + lock (_slotStatsLock) + { + totalBet += amount; + totalPaidOut += result.Won; + } + + return result; + } + + private async Task> GenerateSlotImageAsync(long amount, SlotResult result) + { + long ownedAmount; + await using (var uow = _db.GetDbContext()) + { + ownedAmount = uow.Set().FirstOrDefault(x => x.UserId == ctx.User.Id)?.CurrencyAmount + ?? 0; + } + + var slotBg = await _images.GetSlotBgAsync(); + var bgImage = Image.Load(slotBg, out _); + var numbers = new int[3]; + result.Rolls.CopyTo(numbers, 0); + + Color fontColor = Config.Slots.CurrencyFontColor; + + bgImage.Mutate(x => x.DrawText(new TextOptions(_fonts.DottyFont.CreateFont(65)) + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + WrappingLength = 140, + Origin = new(298, 100) + }, + ((long)result.Won).ToString(), + fontColor)); + + var bottomFont = _fonts.DottyFont.CreateFont(50); + + bgImage.Mutate(x => x.DrawText(new TextOptions(bottomFont) + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + WrappingLength = 135, + Origin = new(196, 480) + }, + amount.ToString(), + fontColor)); + + bgImage.Mutate(x => x.DrawText(new(bottomFont) + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Origin = new(393, 480) + }, + ownedAmount.ToString(), + fontColor)); + //sw.PrintLap("drew red text"); + + for (var i = 0; i < 3; i++) + { + using var img = Image.Load(await _images.GetSlotEmojiAsync(numbers[i])); + bgImage.Mutate(x => x.DrawImage(img, new Point(148 + (105 * i), 217), 1f)); + } + + return bgImage; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/VoteRewardService.cs b/src/EllieBot/Modules/Gambling/VoteRewardService.cs new file mode 100644 index 0000000..62d861b --- /dev/null +++ b/src/EllieBot/Modules/Gambling/VoteRewardService.cs @@ -0,0 +1,106 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Gambling.Services; + +public class VoteModel +{ + [JsonPropertyName("userId")] + public ulong UserId { get; set; } +} + +public class VoteRewardService : IEService, IReadyExecutor +{ + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly ICurrencyService _currencyService; + private readonly GamblingConfigService _gamb; + + public VoteRewardService( + DiscordSocketClient client, + IBotCredentials creds, + ICurrencyService currencyService, + GamblingConfigService gamb) + { + _client = client; + _creds = creds; + _currencyService = currencyService; + _gamb = gamb; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var http = new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false, + ServerCertificateCustomValidationCallback = delegate { return true; } + }); + + while (true) + { + await Task.Delay(30000); + + var topggKey = _creds.Votes?.TopggKey; + var topggServiceUrl = _creds.Votes?.TopggServiceUrl; + + try + { + if (!string.IsNullOrWhiteSpace(topggKey) && !string.IsNullOrWhiteSpace(topggServiceUrl)) + { + http.DefaultRequestHeaders.Authorization = new(topggKey); + var uri = new Uri(new(topggServiceUrl), "topgg/new"); + var res = await http.GetStringAsync(uri); + var data = JsonSerializer.Deserialize>(res); + + if (data is { Count: > 0 }) + { + var ids = data.Select(x => x.UserId).ToList(); + + await _currencyService.AddBulkAsync(ids, + _gamb.Data.VoteReward, + new("vote", "top.gg", "top.gg vote reward")); + + Log.Information("Rewarding {Count} top.gg voters", ids.Count()); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Critical error loading top.gg vote rewards"); + } + + var discordsKey = _creds.Votes?.DiscordsKey; + var discordsServiceUrl = _creds.Votes?.DiscordsServiceUrl; + + try + { + if (!string.IsNullOrWhiteSpace(discordsKey) && !string.IsNullOrWhiteSpace(discordsServiceUrl)) + { + http.DefaultRequestHeaders.Authorization = new(discordsKey); + var res = await http.GetStringAsync(new Uri(new(discordsServiceUrl), "discords/new")); + var data = JsonSerializer.Deserialize>(res); + + if (data is { Count: > 0 }) + { + var ids = data.Select(x => x.UserId).ToList(); + + await _currencyService.AddBulkAsync(ids, + _gamb.Data.VoteReward, + new("vote", "discords", "discords.com vote reward")); + + Log.Information("Rewarding {Count} discords.com voters", ids.Count()); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Critical error loading discords.com vote rewards"); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs b/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs new file mode 100644 index 0000000..5f7db11 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs @@ -0,0 +1,393 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Waifu; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class WaifuClaimCommands : GamblingSubmodule + { + public WaifuClaimCommands(GamblingConfigService gamblingConfService) + : base(gamblingConfService) + { + } + + [Cmd] + public async Task WaifuReset() + { + var price = _service.GetResetPrice(ctx.User); + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.waifu_reset_confirm)) + .WithDescription(GetText(strs.waifu_reset_price(Format.Bold(N(price))))); + + if (!await PromptUserConfirmAsync(embed)) + return; + + if (await _service.TryReset(ctx.User)) + { + await Response().Confirm(strs.waifu_reset).SendAsync(); + return; + } + + await Response().Error(strs.waifu_reset_fail).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WaifuClaim(long amount, [Leftover] IUser target) + { + if (amount < Config.Waifu.MinPrice) + { + await Response().Error(strs.waifu_isnt_cheap(Config.Waifu.MinPrice + CurrencySign)).SendAsync(); + return; + } + + if (target.Id == ctx.User.Id) + { + await Response().Error(strs.waifu_not_yourself).SendAsync(); + return; + } + + var (w, isAffinity, result) = await _service.ClaimWaifuAsync(ctx.User, target, amount); + + if (result == WaifuClaimResult.InsufficientAmount) + { + await Response() + .Error( + strs.waifu_not_enough(N((long)Math.Ceiling(w.Price * (isAffinity ? 0.88f : 1.1f))))) + .SendAsync(); + return; + } + + if (result == WaifuClaimResult.NotEnoughFunds) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var msg = GetText(strs.waifu_claimed(Format.Bold(target.ToString()), N(amount))); + if (w.Affinity?.UserId == ctx.User.Id) + msg += "\n" + GetText(strs.waifu_fulfilled(target, N(w.Price))); + else + msg = " " + msg; + await Response().Confirm(ctx.User.Mention + msg).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task WaifuTransfer(ulong waifuId, IUser newOwner) + { + if (!await _service.WaifuTransfer(ctx.User, waifuId, newOwner)) + { + await Response().Error(strs.waifu_transfer_fail).SendAsync(); + return; + } + + await Response() + .Confirm(strs.waifu_transfer_success(Format.Bold(waifuId.ToString()), + Format.Bold(ctx.User.ToString()), + Format.Bold(newOwner.ToString()))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task WaifuTransfer(IUser waifu, IUser newOwner) + { + if (!await _service.WaifuTransfer(ctx.User, waifu.Id, newOwner)) + { + await Response().Error(strs.waifu_transfer_fail).SendAsync(); + return; + } + + await Response() + .Confirm(strs.waifu_transfer_success(Format.Bold(waifu.ToString()), + Format.Bold(ctx.User.ToString()), + Format.Bold(newOwner.ToString()))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(-1)] + public Task Divorce([Leftover] string target) + { + var waifuUserId = _service.GetWaifuUserId(ctx.User.Id, target); + if (waifuUserId == default) + return Response().Error(strs.waifu_not_yours).SendAsync(); + + return Divorce(waifuUserId); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task Divorce([Leftover] IGuildUser target) + => Divorce(target.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task Divorce([Leftover] ulong targetId) + { + if (targetId == ctx.User.Id) + return; + + var (w, result, amount, remaining) = await _service.DivorceWaifuAsync(ctx.User, targetId); + + if (result == DivorceResult.SucessWithPenalty) + { + await Response() + .Confirm(strs.waifu_divorced_like(Format.Bold(w.Waifu.ToString()), + N(amount))) + .SendAsync(); + } + else if (result == DivorceResult.Success) + await Response().Confirm(strs.waifu_divorced_notlike(N(amount))).SendAsync(); + else if (result == DivorceResult.NotYourWife) + await Response().Error(strs.waifu_not_yours).SendAsync(); + else + { + await Response() + .Error(strs.waifu_recent_divorce( + Format.Bold(((int)remaining?.TotalHours).ToString()), + Format.Bold(remaining?.Minutes.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Affinity([Leftover] IGuildUser user = null) + { + if (user?.Id == ctx.User.Id) + { + await Response().Error(strs.waifu_egomaniac).SendAsync(); + return; + } + + var (oldAff, sucess, remaining) = await _service.ChangeAffinityAsync(ctx.User, user); + if (!sucess) + { + if (remaining is not null) + { + await Response() + .Error(strs.waifu_affinity_cooldown( + Format.Bold(((int)remaining?.TotalHours).ToString()), + Format.Bold(remaining?.Minutes.ToString()))) + .SendAsync(); + } + else + await Response().Error(strs.waifu_affinity_already).SendAsync(); + + return; + } + + if (user is null) + await Response().Confirm(strs.waifu_affinity_reset).SendAsync(); + else if (oldAff is null) + await Response().Confirm(strs.waifu_affinity_set(Format.Bold(user.ToString()))).SendAsync(); + else + { + await Response() + .Confirm(strs.waifu_affinity_changed(Format.Bold(oldAff.ToString()), + Format.Bold(user.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WaifuLb(int page = 1) + { + page--; + + if (page < 0) + return; + + if (page > 100) + page = 100; + + var waifus = _service.GetTopWaifusAtPage(page).ToList(); + + if (waifus.Count == 0) + { + await Response().Confirm(strs.waifus_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.waifus_top_waifus)).WithOkColor(); + + var i = 0; + foreach (var w in waifus) + { + var j = i++; + embed.AddField("#" + ((page * 9) + j + 1) + " - " + N(w.Price), GetLbString(w)); + } + + await Response().Embed(embed).SendAsync(); + } + + private string GetLbString(WaifuLbResult w) + { + var claimer = "no one"; + var status = string.Empty; + + var waifuUsername = w.Username.TrimTo(20); + var claimerUsername = w.Claimer?.TrimTo(20); + + if (w.Claimer is not null) + claimer = $"{claimerUsername}#{w.ClaimerDiscrim}"; + if (w.Affinity is null) + status = $"... but {waifuUsername}'s heart is empty"; + else if (w.Affinity + w.AffinityDiscrim == w.Claimer + w.ClaimerDiscrim) + status = $"... and {waifuUsername} likes {claimerUsername} too <3"; + else + status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}#{w.AffinityDiscrim}"; + return $"**{waifuUsername}#{w.Discrim}** - claimed by **{claimer}**\n\t{status}"; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task WaifuInfo([Leftover] IUser target = null) + { + if (target is null) + target = ctx.User; + + return InternalWaifuInfo(target.Id, target.ToString()); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task WaifuInfo(ulong targetId) + => InternalWaifuInfo(targetId); + + private async Task InternalWaifuInfo(ulong targetId, string name = null) + { + var wi = await _service.GetFullWaifuInfoAsync(targetId); + var affInfo = _service.GetAffinityTitle(wi.AffinityCount); + + var waifuItems = _service.GetWaifuItems().ToDictionary(x => x.ItemEmoji, x => x); + + var nobody = GetText(strs.nobody); + var itemList = await _service.GetItems(wi.WaifuId); + var itemsStr = !itemList.Any() + ? "-" + : string.Join("\n", + itemList.Where(x => waifuItems.TryGetValue(x.ItemEmoji, out _)) + .OrderByDescending(x => waifuItems[x.ItemEmoji].Price) + .GroupBy(x => x.ItemEmoji) + .Take(60) + .Select(x => $"{x.Key} x{x.Count(),-3}") + .Chunk(2) + .Select(x => string.Join(" ", x))); + + var claimsNames = (await _service.GetClaimNames(wi.WaifuId)); + var claimsStr = claimsNames + .Shuffle() + .Take(30) + .Join('\n'); + + var fansList = await _service.GetFansNames(wi.WaifuId); + var fansStr = fansList + .Shuffle() + .Take(30) + .Select((x) => claimsNames.Contains(x) ? $"{x} 💞" : x) + .Join('\n'); + + if (string.IsNullOrWhiteSpace(fansStr)) + fansStr = "-"; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.waifu) + + " " + + (wi.FullName ?? name ?? targetId.ToString()) + + " - \"the " + + _service.GetClaimTitle(wi.ClaimCount) + + "\"") + .AddField(GetText(strs.price), N(wi.Price), true) + .AddField(GetText(strs.claimed_by), wi.ClaimerName ?? nobody, true) + .AddField(GetText(strs.likes), wi.AffinityName ?? nobody, true) + .AddField(GetText(strs.changes_of_heart), $"{wi.AffinityCount} - \"the {affInfo}\"", true) + .AddField(GetText(strs.divorces), wi.DivorceCount.ToString(), true) + .AddField("\u200B", "\u200B", true) + .AddField(GetText(strs.fans(fansList.Count)), fansStr, true) + .AddField($"Waifus ({wi.ClaimCount})", + wi.ClaimCount == 0 ? nobody : claimsStr, + true) + .AddField(GetText(strs.gifts), itemsStr, true); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task WaifuGift(int page = 1) + { + if (--page < 0 || page > (Config.Waifu.Items.Count - 1) / 9) + return; + + var waifuItems = _service.GetWaifuItems(); + await Response() + .Paginated() + .Items(waifuItems.OrderBy(x => x.Negative) + .ThenBy(x => x.Price) + .ToList()) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.waifu_gift_shop)).WithOkColor(); + + items + .ToList() + .ForEach(x => embed.AddField( + $"{(!x.Negative ? string.Empty : "\\💔")} {x.ItemEmoji} {x.Name}", + Format.Bold(N(x.Price)), + true)); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task WaifuGift(string itemName, [Leftover] IUser waifu) + { + if (waifu.Id == ctx.User.Id) + return; + + var allItems = _service.GetWaifuItems(); + var item = allItems.FirstOrDefault(x => x.Name.ToLowerInvariant() == itemName.ToLowerInvariant()); + if (item is null) + { + await Response().Error(strs.waifu_gift_not_exist).SendAsync(); + return; + } + + var sucess = await _service.GiftWaifuAsync(ctx.User, waifu, item); + + if (sucess) + { + await Response() + .Confirm(strs.waifu_gift(Format.Bold(item + " " + item.ItemEmoji), + Format.Bold(waifu.ToString()))) + .SendAsync(); + } + else + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs b/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs new file mode 100644 index 0000000..dded8a9 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs @@ -0,0 +1,582 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Waifu; + +namespace EllieBot.Modules.Gambling.Services; + +public class WaifuService : IEService, IReadyExecutor +{ + private readonly DbService _db; + private readonly ICurrencyService _cs; + private readonly IBotCache _cache; + private readonly GamblingConfigService _gss; + private readonly IBotCredentials _creds; + private readonly DiscordSocketClient _client; + + public WaifuService( + DbService db, + ICurrencyService cs, + IBotCache cache, + GamblingConfigService gss, + IBotCredentials creds, + DiscordSocketClient client) + { + _db = db; + _cs = cs; + _cache = cache; + _gss = gss; + _creds = creds; + _client = client; + } + + public async Task WaifuTransfer(IUser owner, ulong waifuId, IUser newOwner) + { + if (owner.Id == newOwner.Id || waifuId == newOwner.Id) + return false; + + var settings = _gss.Data; + + await using var uow = _db.GetDbContext(); + var waifu = uow.Set().ByWaifuUserId(waifuId); + var ownerUser = uow.GetOrCreateUser(owner); + + // owner has to be the owner of the waifu + if (waifu is null || waifu.ClaimerId != ownerUser.Id) + return false; + + // if waifu likes the person, gotta pay the penalty + if (waifu.AffinityId == ownerUser.Id) + { + if (!await _cs.RemoveAsync(owner.Id, (long)(waifu.Price * 0.6), new("waifu", "affinity-penalty"))) + // unable to pay 60% penalty + return false; + + waifu.Price = (long)(waifu.Price * 0.7); // half of 60% = 30% price reduction + if (waifu.Price < settings.Waifu.MinPrice) + waifu.Price = settings.Waifu.MinPrice; + } + else // if not, pay 10% fee + { + if (!await _cs.RemoveAsync(owner.Id, waifu.Price / 10, new("waifu", "transfer"))) + return false; + + waifu.Price = (long)(waifu.Price * 0.95); // half of 10% = 5% price reduction + if (waifu.Price < settings.Waifu.MinPrice) + waifu.Price = settings.Waifu.MinPrice; + } + + //new claimerId is the id of the new owner + var newOwnerUser = uow.GetOrCreateUser(newOwner); + waifu.ClaimerId = newOwnerUser.Id; + + await uow.SaveChangesAsync(); + + return true; + } + + public long GetResetPrice(IUser user) + { + var settings = _gss.Data; + using var uow = _db.GetDbContext(); + var waifu = uow.Set().ByWaifuUserId(user.Id); + + if (waifu is null) + return settings.Waifu.MinPrice; + + var divorces = uow.Set().Count(x + => x.Old != null && x.Old.UserId == user.Id && x.UpdateType == WaifuUpdateType.Claimed && x.New == null); + var affs = uow.Set().AsQueryable() + .Where(w => w.User.UserId == user.Id + && w.UpdateType == WaifuUpdateType.AffinityChanged + && w.New != null) + .ToList() + .GroupBy(x => x.New) + .Count(); + + return (long)Math.Ceiling(waifu.Price * 1.25f) + + ((divorces + affs + 2) * settings.Waifu.Multipliers.WaifuReset); + } + + public async Task TryReset(IUser user) + { + await using var uow = _db.GetDbContext(); + var price = GetResetPrice(user); + if (!await _cs.RemoveAsync(user.Id, price, new("waifu", "reset"))) + return false; + + var affs = uow.Set().AsQueryable() + .Where(w => w.User.UserId == user.Id + && w.UpdateType == WaifuUpdateType.AffinityChanged + && w.New != null); + + var divorces = uow.Set().AsQueryable() + .Where(x => x.Old != null + && x.Old.UserId == user.Id + && x.UpdateType == WaifuUpdateType.Claimed + && x.New == null); + + //reset changes of heart to 0 + uow.Set().RemoveRange(affs); + //reset divorces to 0 + uow.Set().RemoveRange(divorces); + var waifu = uow.Set().ByWaifuUserId(user.Id); + //reset price, remove items + //remove owner, remove affinity + waifu.Price = 50; + waifu.Items.Clear(); + waifu.ClaimerId = null; + waifu.AffinityId = null; + + //wives stay though + + await uow.SaveChangesAsync(); + + return true; + } + + public async Task<(WaifuInfo, bool, WaifuClaimResult)> ClaimWaifuAsync(IUser user, IUser target, long amount) + { + var settings = _gss.Data; + WaifuClaimResult result; + WaifuInfo w; + bool isAffinity; + await using (var uow = _db.GetDbContext()) + { + w = uow.Set().ByWaifuUserId(target.Id); + isAffinity = w?.Affinity?.UserId == user.Id; + if (w is null) + { + var claimer = uow.GetOrCreateUser(user); + var waifu = uow.GetOrCreateUser(target); + if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim"))) + result = WaifuClaimResult.NotEnoughFunds; + else + { + uow.Set().Add(w = new() + { + Waifu = waifu, + Claimer = claimer, + Affinity = null, + Price = amount + }); + uow.Set().Add(new() + { + User = waifu, + Old = null, + New = claimer, + UpdateType = WaifuUpdateType.Claimed + }); + result = WaifuClaimResult.Success; + } + } + else if (isAffinity && amount > w.Price * settings.Waifu.Multipliers.CrushClaim) + { + if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim"))) + result = WaifuClaimResult.NotEnoughFunds; + else + { + var oldClaimer = w.Claimer; + w.Claimer = uow.GetOrCreateUser(user); + w.Price = amount + (amount / 4); + result = WaifuClaimResult.Success; + + uow.Set().Add(new() + { + User = w.Waifu, + Old = oldClaimer, + New = w.Claimer, + UpdateType = WaifuUpdateType.Claimed + }); + } + } + else if (amount >= w.Price * settings.Waifu.Multipliers.NormalClaim) // if no affinity + { + if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim"))) + result = WaifuClaimResult.NotEnoughFunds; + else + { + var oldClaimer = w.Claimer; + w.Claimer = uow.GetOrCreateUser(user); + w.Price = amount; + result = WaifuClaimResult.Success; + + uow.Set().Add(new() + { + User = w.Waifu, + Old = oldClaimer, + New = w.Claimer, + UpdateType = WaifuUpdateType.Claimed + }); + } + } + else + result = WaifuClaimResult.InsufficientAmount; + + + await uow.SaveChangesAsync(); + } + + return (w, isAffinity, result); + } + + public async Task<(DiscordUser, bool, TimeSpan?)> ChangeAffinityAsync(IUser user, IGuildUser target) + { + DiscordUser oldAff = null; + var success = false; + TimeSpan? remaining = null; + await using (var uow = _db.GetDbContext()) + { + var w = uow.Set().ByWaifuUserId(user.Id); + var newAff = target is null ? null : uow.GetOrCreateUser(target); + if (w?.Affinity?.UserId == target?.Id) + { + return (null, false, null); + } + + remaining = await _cache.GetRatelimitAsync(GetAffinityKey(user.Id), + 30.Minutes()); + + if (remaining is not null) + { + } + else if (w is null) + { + var thisUser = uow.GetOrCreateUser(user); + uow.Set().Add(new() + { + Affinity = newAff, + Waifu = thisUser, + Price = 1, + Claimer = null + }); + success = true; + + uow.Set().Add(new() + { + User = thisUser, + Old = null, + New = newAff, + UpdateType = WaifuUpdateType.AffinityChanged + }); + } + else + { + if (w.Affinity is not null) + oldAff = w.Affinity; + w.Affinity = newAff; + success = true; + + uow.Set().Add(new() + { + User = w.Waifu, + Old = oldAff, + New = newAff, + UpdateType = WaifuUpdateType.AffinityChanged + }); + } + + await uow.SaveChangesAsync(); + } + + return (oldAff, success, remaining); + } + + public IEnumerable GetTopWaifusAtPage(int page, int perPage = 9) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetTop(perPage, page * perPage); + } + + public ulong GetWaifuUserId(ulong ownerId, string name) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetWaifuUserId(ownerId, name); + } + + private static TypedKey GetDivorceKey(ulong userId) + => new($"waifu:divorce_cd:{userId}"); + + private static TypedKey GetAffinityKey(ulong userId) + => new($"waifu:affinity:{userId}"); + + public async Task<(WaifuInfo, DivorceResult, long, TimeSpan?)> DivorceWaifuAsync(IUser user, ulong targetId) + { + DivorceResult result; + TimeSpan? remaining = null; + long amount = 0; + WaifuInfo w; + await using (var uow = _db.GetDbContext()) + { + w = uow.Set().ByWaifuUserId(targetId); + if (w?.Claimer is null || w.Claimer.UserId != user.Id) + result = DivorceResult.NotYourWife; + else + { + remaining = await _cache.GetRatelimitAsync(GetDivorceKey(user.Id), 6.Hours()); + if (remaining is TimeSpan rem) + { + result = DivorceResult.Cooldown; + return (w, result, amount, rem); + } + + amount = w.Price / 2; + + if (w.Affinity?.UserId == user.Id) + { + await _cs.AddAsync(w.Waifu.UserId, amount, new("waifu", "compensation")); + w.Price = (long)Math.Floor(w.Price * _gss.Data.Waifu.Multipliers.DivorceNewValue); + result = DivorceResult.SucessWithPenalty; + } + else + { + await _cs.AddAsync(user.Id, amount, new("waifu", "refund")); + + result = DivorceResult.Success; + } + + var oldClaimer = w.Claimer; + w.Claimer = null; + + uow.Set().Add(new() + { + User = w.Waifu, + Old = oldClaimer, + New = null, + UpdateType = WaifuUpdateType.Claimed + }); + } + + await uow.SaveChangesAsync(); + } + + return (w, result, amount, remaining); + } + + public async Task GiftWaifuAsync(IUser from, IUser giftedWaifu, WaifuItemModel itemObj) + { + if (!await _cs.RemoveAsync(from, itemObj.Price, new("waifu", "item"))) + return false; + + await using var uow = _db.GetDbContext(); + var w = uow.Set().ByWaifuUserId(giftedWaifu.Id, set => set.Include(x => x.Items).Include(x => x.Claimer)); + if (w is null) + { + uow.Set().Add(w = new() + { + Affinity = null, + Claimer = null, + Price = 1, + Waifu = uow.GetOrCreateUser(giftedWaifu) + }); + } + + if (!itemObj.Negative) + { + w.Items.Add(new() + { + Name = itemObj.Name.ToLowerInvariant(), + ItemEmoji = itemObj.ItemEmoji + }); + + if (w.Claimer?.UserId == from.Id) + w.Price += (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.GiftEffect); + else + w.Price += itemObj.Price / 2; + } + else + { + w.Price -= (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.NegativeGiftEffect); + if (w.Price < 1) + w.Price = 1; + } + + await uow.SaveChangesAsync(); + + return true; + } + + public async Task GetFullWaifuInfoAsync(ulong targetId) + { + await using var uow = _db.GetDbContext(); + var wi = await uow.GetWaifuInfoAsync(targetId); + if (wi is null) + { + wi = new() + { + AffinityCount = 0, + AffinityName = null, + ClaimCount = 0, + ClaimerName = null, + DivorceCount = 0, + FullName = null, + Price = 1 + }; + } + + return wi; + } + + public string GetClaimTitle(int count) + { + ClaimTitle title; + if (count == 0) + title = ClaimTitle.Lonely; + else if (count == 1) + title = ClaimTitle.Devoted; + else if (count < 3) + title = ClaimTitle.Rookie; + else if (count < 6) + title = ClaimTitle.Schemer; + else if (count < 10) + title = ClaimTitle.Dilettante; + else if (count < 17) + title = ClaimTitle.Intermediate; + else if (count < 25) + title = ClaimTitle.Seducer; + else if (count < 35) + title = ClaimTitle.Expert; + else if (count < 50) + title = ClaimTitle.Veteran; + else if (count < 75) + title = ClaimTitle.Incubis; + else if (count < 100) + title = ClaimTitle.Harem_King; + else + title = ClaimTitle.Harem_God; + + return title.ToString().Replace('_', ' '); + } + + public string GetAffinityTitle(int count) + { + AffinityTitle title; + if (count < 1) + title = AffinityTitle.Pure; + else if (count < 2) + title = AffinityTitle.Faithful; + else if (count < 4) + title = AffinityTitle.Playful; + else if (count < 8) + title = AffinityTitle.Cheater; + else if (count < 11) + title = AffinityTitle.Tainted; + else if (count < 15) + title = AffinityTitle.Corrupted; + else if (count < 20) + title = AffinityTitle.Lewd; + else if (count < 25) + title = AffinityTitle.Sloot; + else if (count < 35) + title = AffinityTitle.Depraved; + else + title = AffinityTitle.Harlot; + + return title.ToString().Replace('_', ' '); + } + + public IReadOnlyList GetWaifuItems() + { + var conf = _gss.Data; + return conf.Waifu.Items.Select(x + => new WaifuItemModel(x.ItemEmoji, + (long)(x.Price * conf.Waifu.Multipliers.AllGiftPrices), + x.Name, + x.Negative)) + .ToList(); + } + + private static readonly TypedKey _waifuDecayKey = $"waifu:last_decay"; + public async Task OnReadyAsync() + { + // only decay waifu values from shard 0 + if (_client.ShardId != 0) + return; + + while (true) + { + try + { + var multi = _gss.Data.Waifu.Decay.Percent / 100f; + var minPrice = _gss.Data.Waifu.Decay.MinPrice; + var decayInterval = _gss.Data.Waifu.Decay.HourInterval; + + if (multi is < 0f or > 1f || decayInterval < 0) + continue; + + var now = DateTime.UtcNow; + var nowB = now.ToBinary(); + + var result = await _cache.GetAsync(_waifuDecayKey); + + if (result.TryGetValue(out var val)) + { + var lastDecay = DateTime.FromBinary(val); + var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay); + + if (toWait > 0.Hours()) + continue; + } + + await _cache.AddAsync(_waifuDecayKey, nowB); + + await using var uow = _db.GetDbContext(); + + await uow.GetTable() + .Where(x => x.Price > minPrice && x.ClaimerId == null) + .UpdateAsync(old => new() + { + Price = (long)(old.Price * multi) + }); + + } + catch (Exception ex) + { + Log.Error(ex, "Unexpected error occured in waifu decay loop: {ErrorMessage}", ex.Message); + } + finally + { + await Task.Delay(1.Hours()); + } + } + } + + public async Task> GetClaimNames(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => ctx.GetTable() + .Where(wi => wi.ClaimerId == waifuId) + .Select(wi => wi.WaifuId) + .Contains(x.Id)) + .Select(x => $"{x.Username}#{x.Discriminator}") + .ToListAsyncEF(); + } + public async Task> GetFansNames(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => ctx.GetTable() + .Where(wi => wi.AffinityId == waifuId) + .Select(wi => wi.WaifuId) + .Contains(x.Id)) + .Select(x => $"{x.Username}#{x.Discriminator}") + .ToListAsyncEF(); + } + + public async Task> GetItems(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => x.WaifuInfoId == ctx.GetTable() + .Where(x => x.WaifuId == waifuId) + .Select(x => x.Id) + .FirstOrDefault()) + .ToListAsyncEF(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs new file mode 100644 index 0000000..64cf443 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public enum AffinityTitle +{ + Pure, + Faithful, + Playful, + Cheater, + Tainted, + Corrupted, + Lewd, + Sloot, + Depraved, + Harlot +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs new file mode 100644 index 0000000..4b7628f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs @@ -0,0 +1,18 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public enum ClaimTitle +{ + Lonely, + Devoted, + Rookie, + Schemer, + Dilettante, + Intermediate, + Seducer, + Expert, + Veteran, + Incubis, + Harem_King, + Harem_God +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs new file mode 100644 index 0000000..650bc91 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public enum DivorceResult +{ + Success, + SucessWithPenalty, + NotYourWife, + Cooldown +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs new file mode 100644 index 0000000..44c6396 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public class Extensions +{ + +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs new file mode 100644 index 0000000..d68eafb --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public enum WaifuClaimResult +{ + Success, + NotEnoughFunds, + InsufficientAmount +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs b/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs new file mode 100644 index 0000000..fa7c5d5 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WaifuInfo : DbEntity +{ + public int WaifuId { get; set; } + public DiscordUser Waifu { get; set; } + + public int? ClaimerId { get; set; } + public DiscordUser Claimer { get; set; } + + public int? AffinityId { get; set; } + public DiscordUser Affinity { get; set; } + + public long Price { get; set; } + public List Items { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs new file mode 100644 index 0000000..45f3055 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs @@ -0,0 +1,131 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class WaifuExtensions +{ + public static WaifuInfo ByWaifuUserId( + this DbSet waifus, + ulong userId, + Func, IQueryable> includes = null) + { + if (includes is null) + { + return waifus.Include(wi => wi.Waifu) + .Include(wi => wi.Affinity) + .Include(wi => wi.Claimer) + .Include(wi => wi.Items) + .FirstOrDefault(wi => wi.Waifu.UserId == userId); + } + + return includes(waifus).AsQueryable().FirstOrDefault(wi => wi.Waifu.UserId == userId); + } + + public static IEnumerable GetTop(this DbSet waifus, int count, int skip = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + + if (count == 0) + return []; + + return waifus.Include(wi => wi.Waifu) + .Include(wi => wi.Affinity) + .Include(wi => wi.Claimer) + .OrderByDescending(wi => wi.Price) + .Skip(skip) + .Take(count) + .Select(x => new WaifuLbResult + { + Affinity = x.Affinity == null ? null : x.Affinity.Username, + AffinityDiscrim = x.Affinity == null ? null : x.Affinity.Discriminator, + Claimer = x.Claimer == null ? null : x.Claimer.Username, + ClaimerDiscrim = x.Claimer == null ? null : x.Claimer.Discriminator, + Username = x.Waifu.Username, + Discrim = x.Waifu.Discriminator, + Price = x.Price + }) + .ToList(); + } + + public static decimal GetTotalValue(this DbSet waifus) + => waifus.AsQueryable().Where(x => x.ClaimerId != null).Sum(x => x.Price); + + public static ulong GetWaifuUserId(this DbSet waifus, ulong ownerId, string name) + => waifus.AsQueryable() + .AsNoTracking() + .Where(x => x.Claimer.UserId == ownerId && x.Waifu.Username + "#" + x.Waifu.Discriminator == name) + .Select(x => x.Waifu.UserId) + .FirstOrDefault(); + + public static async Task GetWaifuInfoAsync(this DbContext ctx, ulong userId) + { + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + AffinityId = null, + ClaimerId = null, + Price = 1, + WaifuId = ctx.Set().Where(x => x.UserId == userId).Select(x => x.Id).First() + }, + _ => new(), + () => new() + { + WaifuId = ctx.Set().Where(x => x.UserId == userId).Select(x => x.Id).First() + }); + + var toReturn = ctx.Set().AsQueryable() + .Where(w => w.WaifuId + == ctx.Set() + .AsQueryable() + .Where(u => u.UserId == userId) + .Select(u => u.Id) + .FirstOrDefault()) + .Select(w => new WaifuInfoStats + { + WaifuId = w.WaifuId, + FullName = + ctx.Set() + .AsQueryable() + .Where(u => u.UserId == userId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + AffinityCount = + ctx.Set() + .AsQueryable() + .Count(x => x.UserId == w.WaifuId + && x.UpdateType == WaifuUpdateType.AffinityChanged + && x.NewId != null), + AffinityName = + ctx.Set() + .AsQueryable() + .Where(u => u.Id == w.AffinityId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + ClaimCount = ctx.Set().AsQueryable().Count(x => x.ClaimerId == w.WaifuId), + ClaimerName = + ctx.Set() + .AsQueryable() + .Where(u => u.Id == w.ClaimerId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + DivorceCount = + ctx.Set() + .AsQueryable() + .Count(x => x.OldId == w.WaifuId + && x.NewId == null + && x.UpdateType == WaifuUpdateType.Claimed), + Price = w.Price, + }) + .FirstOrDefault(); + + if (toReturn is null) + return null; + + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs new file mode 100644 index 0000000..8add339 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db; + +public class WaifuInfoStats +{ + public int WaifuId { get; init; } + public string FullName { get; init; } + public long Price { get; init; } + public string ClaimerName { get; init; } + public string AffinityName { get; init; } + public int AffinityCount { get; init; } + public int DivorceCount { get; init; } + public int ClaimCount { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs new file mode 100644 index 0000000..89125c8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WaifuItem : DbEntity +{ + public WaifuInfo WaifuInfo { get; set; } + public int? WaifuInfoId { get; set; } + public string ItemEmoji { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs new file mode 100644 index 0000000..f83af4f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WaifuLbResult +{ + public string Username { get; set; } + public string Discrim { get; set; } + + public string Claimer { get; set; } + public string ClaimerDiscrim { get; set; } + + public string Affinity { get; set; } + public string AffinityDiscrim { get; set; } + + public long Price { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs new file mode 100644 index 0000000..736bd0d --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WaifuUpdate : DbEntity +{ + public int UserId { get; set; } + public DiscordUser User { get; set; } + public WaifuUpdateType UpdateType { get; set; } + + public int? OldId { get; set; } + public DiscordUser Old { get; set; } + + public int? NewId { get; set; } + public DiscordUser New { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs new file mode 100644 index 0000000..626bb4c --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public enum WaifuUpdateType +{ + AffinityChanged, + Claimed +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs b/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs new file mode 100644 index 0000000..04b8e76 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs @@ -0,0 +1,19 @@ +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Common; + +public class QuadDeck : Deck +{ + protected override void RefillPool() + { + CardPool = new(52 * 4); + for (var j = 1; j < 14; j++) + for (var i = 1; i < 5; i++) + { + CardPool.Add(new((CardSuit)i, j)); + CardPool.Add(new((CardSuit)i, j)); + CardPool.Add(new((CardSuit)i, j)); + CardPool.Add(new((CardSuit)i, j)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs b/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs new file mode 100644 index 0000000..45c1c7e --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs @@ -0,0 +1,56 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling; + +public class GamblingCleanupService : IGamblingCleanupService, IEService +{ + private readonly DbService _db; + + public GamblingCleanupService(DbService db) + { + _db = db; + } + + public async Task DeleteWaifus() + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + } + + public async Task DeleteWaifu(ulong userId) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .Where(x => x.User.UserId == userId) + .DeleteAsync(); + await ctx.GetTable() + .Where(x => x.WaifuInfo.Waifu.UserId == userId) + .DeleteAsync(); + await ctx.GetTable() + .Where(x => x.Claimer.UserId == userId) + .UpdateAsync(old => new WaifuInfo() + { + ClaimerId = null, + }); + await ctx.GetTable() + .Where(x => x.Waifu.UserId == userId) + .DeleteAsync(); + } + + public async Task DeleteCurrency() + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable().UpdateAsync(_ => new DiscordUser() + { + CurrencyAmount = 0 + }); + + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs b/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs new file mode 100644 index 0000000..8e266b4 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Gambling; + +public interface IGamblingCleanupService +{ + Task DeleteWaifus(); + Task DeleteWaifu(ulong userId); + Task DeleteCurrency(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs b/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs new file mode 100644 index 0000000..4ed31c9 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs @@ -0,0 +1,18 @@ +#nullable disable +using EllieBot.Modules.Gambling; +using EllieBot.Modules.Gambling.Betdraw; +using EllieBot.Modules.Gambling.Rps; +using OneOf; + +namespace EllieBot.Modules.Gambling; + +public interface IGamblingService +{ + Task> LulaAsync(ulong userId, long amount); + Task> BetRollAsync(ulong userId, long amount); + Task> BetFlipAsync(ulong userId, long amount, byte guess); + Task> SlotAsync(ulong userId, long amount); + Task FlipAsync(int count); + Task> RpsAsync(ulong userId, long amount, byte pick); + Task> BetDrawAsync(ulong userId, long amount, byte? maybeGuessValue, byte? maybeGuessColor); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs b/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs new file mode 100644 index 0000000..2412503 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs @@ -0,0 +1,269 @@ +#nullable disable +using EllieBot.Modules.Gambling; +using EllieBot.Modules.Gambling.Betdraw; +using EllieBot.Modules.Gambling.Rps; +using EllieBot.Modules.Gambling.Services; +using OneOf; + +namespace EllieBot.Modules.Gambling; + +public sealed class NewGamblingService : IGamblingService, IEService +{ + private readonly GamblingConfigService _bcs; + private readonly ICurrencyService _cs; + + public NewGamblingService(GamblingConfigService bcs, ICurrencyService cs) + { + _bcs = bcs; + _cs = cs; + } + + public async Task> LulaAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("lula", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new LulaGame(_bcs.Data.LuckyLadder.Multipliers); + var result = game.Spin(amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("lula", "win")); + } + + return result; + } + + public async Task> BetRollAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betroll", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new BetrollGame(_bcs.Data.BetRoll.Pairs + .Select(x => (x.WhenAbove, (decimal)x.MultiplyBy)) + .ToList()); + + var result = game.Roll(amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("betroll", "win")); + } + + return result; + } + + public async Task> BetFlipAsync(ulong userId, long amount, byte guess) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(guess, 1); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betflip", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new BetflipGame(_bcs.Data.BetFlip.Multiplier); + var result = game.Flip(guess, amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("betflip", "win")); + } + + return result; + } + + public async Task> BetDrawAsync(ulong userId, long amount, byte? maybeGuessValue, byte? maybeGuessColor) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (maybeGuessColor is null && maybeGuessValue is null) + throw new ArgumentNullException(); + + if (maybeGuessColor > 1) + throw new ArgumentOutOfRangeException(nameof(maybeGuessColor)); + + if (maybeGuessValue > 1) + throw new ArgumentOutOfRangeException(nameof(maybeGuessValue)); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betdraw", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new BetdrawGame(); + var result = game.Draw((BetdrawValueGuess?)maybeGuessValue, (BetdrawColorGuess?)maybeGuessColor, amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("betdraw", "win")); + } + + return result; + } + + public async Task> SlotAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("slot", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new SlotGame(); + var result = game.Spin(amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("slot", "won")); + } + + return result; + } + + public Task FlipAsync(int count) + { + ArgumentOutOfRangeException.ThrowIfLessThan(count, 1); + + var game = new BetflipGame(0); + + var results = new FlipResult[count]; + for (var i = 0; i < count; i++) + { + results[i] = new() + { + Side = game.Flip(0, 0).Side + }; + } + + return Task.FromResult(results); + } + + // + // + // private readonly ConcurrentDictionary _decks = new ConcurrentDictionary(); + // + // public override Task DeckShuffle(DeckShuffleRequest request, ServerCallContext context) + // { + // _decks.AddOrUpdate(request.Id, new Deck(), (key, old) => new Deck()); + // return Task.FromResult(new DeckShuffleReply { }); + // } + // + // public override Task DeckDraw(DeckDrawRequest request, ServerCallContext context) + // { + // if (request.Count < 1 || request.Count > 10) + // throw new ArgumentOutOfRangeException(nameof(request.Id)); + // + // var deck = request.UseNew + // ? new Deck() + // : _decks.GetOrAdd(request.Id, new Deck()); + // + // var list = new List(request.Count); + // for (int i = 0; i < request.Count; i++) + // { + // var card = deck.DrawNoRestart(); + // if (card is null) + // { + // if (i == 0) + // { + // deck.Restart(); + // list.Add(deck.DrawNoRestart()); + // continue; + // } + // + // break; + // } + // + // list.Add(card); + // } + // + // var cards = list + // .Select(x => new Card + // { + // Name = x.ToString().ToLowerInvariant().Replace(' ', '_'), + // Number = x.Number, + // Suit = (CardSuit) x.Suit + // }); + // + // var toReturn = new DeckDrawReply(); + // toReturn.Cards.AddRange(cards); + // + // return Task.FromResult(toReturn); + // } + // + + public async Task> RpsAsync(ulong userId, long amount, byte pick) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + ArgumentOutOfRangeException.ThrowIfGreaterThan(pick, 2); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("rps", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var rps = new RpsGame(); + var result = rps.Play((RpsPick)pick, amount); + + var won = (long)result.Won; + if (won > 0) + { + var extra = result.Result switch + { + RpsResultType.Draw => "draw", + RpsResultType.Win => "win", + _ => "lose" + }; + + await _cs.AddAsync(userId, won, new("rps", extra)); + } + + return result; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs b/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs new file mode 100644 index 0000000..24b634a --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs @@ -0,0 +1,139 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common; + +public class RollDuelGame +{ + public enum Reason + { + Normal, + NoFunds, + Timeout + } + + public enum State + { + Waiting, + Running, + Ended + } + + public event Func OnGameTick; + public event Func OnEnded; + + public ulong P1 { get; } + public ulong P2 { get; } + + public long Amount { get; } + + public List<(int, int)> Rolls { get; } = new(); + public State CurrentState { get; private set; } + public ulong Winner { get; private set; } + + private readonly ulong _botId; + + private readonly ICurrencyService _cs; + + private readonly Timer _timeoutTimer; + private readonly EllieRandom _rng = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + + public RollDuelGame( + ICurrencyService cs, + ulong botId, + ulong p1, + ulong p2, + long amount) + { + P1 = p1; + P2 = p2; + _botId = botId; + Amount = amount; + _cs = cs; + + _timeoutTimer = new(async delegate + { + await _locker.WaitAsync(); + try + { + if (CurrentState != State.Waiting) + return; + CurrentState = State.Ended; + await OnEnded?.Invoke(this, Reason.Timeout); + } + catch { } + finally + { + _locker.Release(); + } + }, + null, + TimeSpan.FromSeconds(15), + TimeSpan.FromMilliseconds(-1)); + } + + public async Task StartGame() + { + await _locker.WaitAsync(); + try + { + if (CurrentState != State.Waiting) + return; + _timeoutTimer.Change(Timeout.Infinite, Timeout.Infinite); + CurrentState = State.Running; + } + finally + { + _locker.Release(); + } + + if (!await _cs.RemoveAsync(P1, Amount, new("rollduel", "bet"))) + { + await OnEnded?.Invoke(this, Reason.NoFunds); + CurrentState = State.Ended; + return; + } + + if (!await _cs.RemoveAsync(P2, Amount, new("rollduel", "bet"))) + { + await _cs.AddAsync(P1, Amount, new("rollduel", "refund")); + await OnEnded?.Invoke(this, Reason.NoFunds); + CurrentState = State.Ended; + return; + } + + int n1, n2; + do + { + n1 = _rng.Next(0, 5); + n2 = _rng.Next(0, 5); + Rolls.Add((n1, n2)); + if (n1 != n2) + { + if (n1 > n2) + Winner = P1; + else + Winner = P2; + var won = (long)(Amount * 2 * 0.98f); + await _cs.AddAsync(Winner, won, new("rollduel", "win")); + + await _cs.AddAsync(_botId, (Amount * 2) - won, new("rollduel", "fee")); + } + + try { await OnGameTick?.Invoke(this); } + catch { } + + await Task.Delay(2500); + if (n1 != n2) + break; + } while (true); + + CurrentState = State.Ended; + await OnEnded?.Invoke(this, Reason.Normal); + } +} + +public struct RollDuelChallenge +{ + public ulong Player1 { get; set; } + public ulong Player2 { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs new file mode 100644 index 0000000..42c6f09 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs @@ -0,0 +1,95 @@ +using System.Text.RegularExpressions; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Services; +using NCalc; +using OneOf; + +namespace EllieBot.Common.TypeReaders; + +public class BaseShmartInputAmountReader +{ + private static readonly Regex _percentRegex = new(@"^((?100|\d{1,2})%)$", RegexOptions.Compiled); + protected readonly DbService _db; + protected readonly GamblingConfigService _gambling; + + public BaseShmartInputAmountReader(DbService db, GamblingConfigService gambling) + { + _db = db; + _gambling = gambling; + } + + public async ValueTask>> ReadAsync(ICommandContext context, string input) + { + var i = input.Trim().ToUpperInvariant(); + + i = i.Replace("K", "000"); + + //can't add m because it will conflict with max atm + + if (await TryHandlePercentage(context, i) is long num) + { + return num; + } + + try + { + var expr = new Expression(i, EvaluateOptions.IgnoreCase); + expr.EvaluateParameter += (str, ev) => EvaluateParam(str, ev, context).GetAwaiter().GetResult(); + return (long)decimal.Parse(expr.Evaluate().ToString()!); + } + catch (Exception) + { + return new OneOf.Types.Error($"Invalid input: {input}"); + } + } + + private async Task EvaluateParam(string name, ParameterArgs args, ICommandContext ctx) + { + switch (name.ToUpperInvariant()) + { + case "PI": + args.Result = Math.PI; + break; + case "E": + args.Result = Math.E; + break; + case "ALL": + case "ALLIN": + args.Result = await Cur(ctx); + break; + case "HALF": + args.Result = await Cur(ctx) / 2; + break; + case "MAX": + args.Result = await Max(ctx); + break; + } + } + + protected virtual async Task Cur(ICommandContext ctx) + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetUserCurrencyAsync(ctx.User.Id); + } + + protected virtual async Task Max(ICommandContext ctx) + { + var settings = _gambling.Data; + var max = settings.MaxBet; + return max == 0 ? await Cur(ctx) : max; + } + + private async Task TryHandlePercentage(ICommandContext ctx, string input) + { + var m = _percentRegex.Match(input); + + if (m.Captures.Count == 0) + return null; + + if (!long.TryParse(m.Groups["num"].ToString(), out var percent)) + return null; + + return (long)(await Cur(ctx) * (percent / 100.0f)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs new file mode 100644 index 0000000..bcb7c20 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs @@ -0,0 +1,21 @@ +using EllieBot.Modules.Gambling.Bank; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Common.TypeReaders; + +public sealed class ShmartBankInputAmountReader : BaseShmartInputAmountReader +{ + private readonly IBankService _bank; + + public ShmartBankInputAmountReader(IBankService bank, DbService db, GamblingConfigService gambling) + : base(db, gambling) + { + _bank = bank; + } + + protected override Task Cur(ICommandContext ctx) + => _bank.GetBalanceAsync(ctx.User.Id); + + protected override Task Max(ICommandContext ctx) + => Cur(ctx); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs new file mode 100644 index 0000000..cd94058 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs @@ -0,0 +1,57 @@ +#nullable disable +using EllieBot.Modules.Gambling.Bank; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Common.TypeReaders; + +public sealed class BalanceTypeReader : TypeReader +{ + private readonly BaseShmartInputAmountReader _tr; + + public BalanceTypeReader(DbService db, GamblingConfigService gambling) + { + _tr = new BaseShmartInputAmountReader(db, gambling); + } + + public override async Task ReadAsync( + ICommandContext context, + string input, + IServiceProvider services) + { + + var result = await _tr.ReadAsync(context, input); + + if (result.TryPickT0(out var val, out var err)) + { + return Discord.Commands.TypeReaderResult.FromSuccess(val); + } + + return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, err.Value); + } +} + +public sealed class BankBalanceTypeReader : TypeReader +{ + private readonly ShmartBankInputAmountReader _tr; + + public BankBalanceTypeReader(IBankService bank, DbService db, GamblingConfigService gambling) + { + _tr = new ShmartBankInputAmountReader(bank, db, gambling); + } + + public override async Task ReadAsync( + ICommandContext context, + string input, + IServiceProvider services) + { + + var result = await _tr.ReadAsync(context, input); + + if (result.TryPickT0(out var val, out var err)) + { + return Discord.Commands.TypeReaderResult.FromSuccess(val); + } + + return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, err.Value); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs b/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs new file mode 100644 index 0000000..ae8ba56 --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs @@ -0,0 +1,200 @@ +#nullable disable +using CommandLine; +using System.Collections.Immutable; + +namespace EllieBot.Modules.Games.Common.Acrophobia; + +public sealed class AcrophobiaGame : IDisposable +{ + public enum Phase + { + Submission, + Voting, + Ended + } + + public enum UserInputResult + { + Submitted, + SubmissionFailed, + Voted, + VotingFailed, + Failed + } + + public event Func OnStarted = delegate { return Task.CompletedTask; }; + + public event Func>, Task> OnVotingStarted = + delegate { return Task.CompletedTask; }; + + public event Func OnUserVoted = delegate { return Task.CompletedTask; }; + + public event Func>, Task> OnEnded = delegate + { + return Task.CompletedTask; + }; + + public Phase CurrentPhase { get; private set; } = Phase.Submission; + public ImmutableArray StartingLetters { get; private set; } + public Options Opts { get; } + + private readonly Dictionary _submissions = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly EllieRandom _rng; + + private readonly HashSet _usersWhoVoted = []; + + public AcrophobiaGame(Options options) + { + Opts = options; + _rng = new(); + InitializeStartingLetters(); + } + + public async Task Run() + { + await OnStarted(this); + await Task.Delay(Opts.SubmissionTime * 1000); + await _locker.WaitAsync(); + try + { + if (_submissions.Count == 0) + { + CurrentPhase = Phase.Ended; + await OnVotingStarted(this, ImmutableArray.Create>()); + return; + } + + if (_submissions.Count == 1) + { + CurrentPhase = Phase.Ended; + await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray()); + return; + } + + CurrentPhase = Phase.Voting; + + await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray()); + } + finally { _locker.Release(); } + + await Task.Delay(Opts.VoteTime * 1000); + await _locker.WaitAsync(); + try + { + CurrentPhase = Phase.Ended; + await OnEnded(this, _submissions.ToArray().ToImmutableArray()); + } + finally { _locker.Release(); } + } + + private void InitializeStartingLetters() + { + var wordCount = _rng.Next(3, 6); + + var lettersArr = new char[wordCount]; + + for (var i = 0; i < wordCount; i++) + { + var randChar = (char)_rng.Next(65, 91); + lettersArr[i] = randChar == 'X' ? (char)_rng.Next(65, 88) : randChar; + } + + StartingLetters = lettersArr.ToImmutableArray(); + } + + public async Task UserInput(ulong userId, string userName, string input) + { + var user = new AcrophobiaUser(userId, userName, input.ToLowerInvariant().ToTitleCase()); + + await _locker.WaitAsync(); + try + { + switch (CurrentPhase) + { + case Phase.Submission: + if (_submissions.ContainsKey(user) || !IsValidAnswer(input)) + break; + + _submissions.Add(user, 0); + return true; + case Phase.Voting: + AcrophobiaUser toVoteFor; + if (!int.TryParse(input, out var index) + || --index < 0 + || index >= _submissions.Count + || (toVoteFor = _submissions.ToArray()[index].Key).UserId == user.UserId + || !_usersWhoVoted.Add(userId)) + break; + ++_submissions[toVoteFor]; + _ = Task.Run(() => OnUserVoted(userName)); + return true; + } + + return false; + } + finally + { + _locker.Release(); + } + } + + private bool IsValidAnswer(string input) + { + input = input.ToUpperInvariant(); + + var inputWords = input.Split(' '); + + if (inputWords.Length + != StartingLetters.Length) // number of words must be the same as the number of the starting letters + return false; + + for (var i = 0; i < StartingLetters.Length; i++) + { + var letter = StartingLetters[i]; + + if (!inputWords[i] + .StartsWith(letter.ToString(), StringComparison.InvariantCulture)) // all first letters must match + return false; + } + + return true; + } + + public void Dispose() + { + CurrentPhase = Phase.Ended; + OnStarted = null; + OnEnded = null; + OnUserVoted = null; + OnVotingStarted = null; + _usersWhoVoted.Clear(); + _submissions.Clear(); + _locker.Dispose(); + } + + public class Options : IEllieCommandOptions + { + [Option('s', + "submission-time", + Required = false, + Default = 60, + HelpText = "Time after which the submissions are closed and voting starts.")] + public int SubmissionTime { get; set; } = 60; + + [Option('v', + "vote-time", + Required = false, + Default = 60, + HelpText = "Time after which the voting is closed and the winner is declared.")] + public int VoteTime { get; set; } = 30; + + public void NormalizeOptions() + { + if (SubmissionTime is < 15 or > 300) + SubmissionTime = 60; + if (VoteTime is < 15 or > 120) + VoteTime = 30; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs b/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs new file mode 100644 index 0000000..2de2917 --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs @@ -0,0 +1,22 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.Acrophobia; + +public class AcrophobiaUser +{ + public string UserName { get; } + public ulong UserId { get; } + public string Input { get; } + + public AcrophobiaUser(ulong userId, string userName, string input) + { + UserName = userName; + UserId = userId; + Input = input; + } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is AcrophobiaUser x ? x.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs b/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs new file mode 100644 index 0000000..30defba --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs @@ -0,0 +1,140 @@ +#nullable disable +using EllieBot.Modules.Games.Common.Acrophobia; +using EllieBot.Modules.Games.Services; +using System.Collections.Immutable; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class AcropobiaCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public AcropobiaCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Acrophobia(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new AcrophobiaGame.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + var game = new AcrophobiaGame(options); + if (_service.AcrophobiaGames.TryAdd(channel.Id, game)) + { + try + { + game.OnStarted += Game_OnStarted; + game.OnEnded += Game_OnEnded; + game.OnVotingStarted += Game_OnVotingStarted; + game.OnUserVoted += Game_OnUserVoted; + _client.MessageReceived += ClientMessageReceived; + await game.Run(); + } + finally + { + _client.MessageReceived -= ClientMessageReceived; + _service.AcrophobiaGames.TryRemove(channel.Id, out game); + game?.Dispose(); + } + } + else + await Response().Error(strs.acro_running).SendAsync(); + + Task ClientMessageReceived(SocketMessage msg) + { + if (msg.Channel.Id != ctx.Channel.Id) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + var success = await game.UserInput(msg.Author.Id, msg.Author.ToString(), msg.Content); + if (success) + await msg.DeleteAsync(); + } + catch { } + }); + + return Task.CompletedTask; + } + } + + private Task Game_OnStarted(AcrophobiaGame game) + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia)) + .WithDescription( + GetText(strs.acro_started(Format.Bold(string.Join(".", game.StartingLetters))))) + .WithFooter(GetText(strs.acro_started_footer(game.Opts.SubmissionTime))); + + return Response().Embed(embed).SendAsync(); + } + + private Task Game_OnUserVoted(string user) + => Response().Confirm(GetText(strs.acrophobia), GetText(strs.acro_vote_cast(Format.Bold(user)))).SendAsync(); + + private async Task Game_OnVotingStarted( + AcrophobiaGame game, + ImmutableArray> submissions) + { + if (submissions.Length == 0) + { + await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_ended_no_sub)).SendAsync(); + return; + } + + if (submissions.Length == 1) + { + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithDescription(GetText( + strs.acro_winner_only( + Format.Bold(submissions.First().Key.UserName)))) + .WithFooter(submissions.First().Key.Input)).SendAsync(); + return; + } + + + var i = 0; + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia) + " - " + GetText(strs.submissions_closed)) + .WithDescription(GetText(strs.acro_nym_was( + Format.Bold(string.Join(".", game.StartingLetters)) + + "\n" + + $@"-- +{submissions.Aggregate("", (agg, cur) => agg + $"`{++i}.` **{cur.Key.Input}**\n")} +--"))) + .WithFooter(GetText(strs.acro_vote)); + + await Response().Embed(embed).SendAsync(); + } + + private async Task Game_OnEnded(AcrophobiaGame game, ImmutableArray> votes) + { + if (!votes.Any() || votes.All(x => x.Value == 0)) + { + await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_no_votes_cast)).SendAsync(); + return; + } + + var table = votes.OrderByDescending(v => v.Value); + var winner = table.First(); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia)) + .WithDescription(GetText(strs.acro_winner(Format.Bold(winner.Key.UserName), + Format.Bold(winner.Value.ToString())))) + .WithFooter(winner.Key.Input); + + await Response().Embed(embed).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs b/src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs new file mode 100644 index 0000000..30dc17e --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs @@ -0,0 +1,215 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using EllieBot.Modules.Games.Common; +using EllieBot.Modules.Games.Common.ChatterBot; +using EllieBot.Modules.Patronage; +using EllieBot.Modules.Permissions; + +namespace EllieBot.Modules.Games.Services; + +public class ChatterBotService : IExecOnMessage +{ + public ConcurrentDictionary> ChatterBotGuilds { get; } + + public int Priority + => 1; + + private readonly FeatureLimitKey _flKey; + + private readonly DiscordSocketClient _client; + private readonly IPermissionChecker _perms; + private readonly CommandHandler _cmd; + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _httpFactory; + private readonly IPatronageService _ps; + private readonly GamesConfigService _gcs; + private readonly IMessageSenderService _sender; + + public ChatterBotService( + DiscordSocketClient client, + IPermissionChecker perms, + IBot bot, + CommandHandler cmd, + IHttpClientFactory factory, + IBotCredentials creds, + IPatronageService ps, + GamesConfigService gcs, + IMessageSenderService sender) + { + _client = client; + _perms = perms; + _cmd = cmd; + _creds = creds; + _sender = sender; + _httpFactory = factory; + _ps = ps; + _perms = perms; + _gcs = gcs; + + _flKey = new FeatureLimitKey() + { + Key = CleverBotResponseStr.CLEVERBOT_RESPONSE, + PrettyName = "Cleverbot Replies" + }; + + ChatterBotGuilds = new(bot.AllGuildConfigs + .Where(gc => gc.CleverbotEnabled) + .ToDictionary(gc => gc.GuildId, + _ => new Lazy(() => CreateSession(), true))); + } + + public IChatterBotSession CreateSession() + { + switch (_gcs.Data.ChatBot) + { + case ChatBotImplementation.Cleverbot: + if (!string.IsNullOrWhiteSpace(_creds.CleverbotApiKey)) + return new OfficialCleverbotSession(_creds.CleverbotApiKey, _httpFactory); + + Log.Information("Cleverbot will not work as the api key is missing"); + return null; + case ChatBotImplementation.Gpt3: + if (!string.IsNullOrWhiteSpace(_creds.Gpt3ApiKey)) + return new OfficialGpt3Session(_creds.Gpt3ApiKey, + _gcs.Data.ChatGpt.ModelName, + _gcs.Data.ChatGpt.ChatHistory, + _gcs.Data.ChatGpt.MaxTokens, + _gcs.Data.ChatGpt.MinTokens, + _gcs.Data.ChatGpt.PersonalityPrompt, + _client.CurrentUser.Username, + _httpFactory); + + Log.Information("Gpt3 will not work as the api key is missing"); + return null; + default: + return null; + } + } + + public string PrepareMessage(IUserMessage msg, out IChatterBotSession cleverbot) + { + var channel = msg.Channel as ITextChannel; + cleverbot = null; + + if (channel is null) + return null; + + if (!ChatterBotGuilds.TryGetValue(channel.Guild.Id, out var lazyCleverbot)) + return null; + + cleverbot = lazyCleverbot.Value; + + var ellieId = _client.CurrentUser.Id; + var normalMention = $"<@{ellieId}> "; + var nickMention = $"<@!{ellieId}> "; + string message; + if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture)) + message = msg.Content[normalMention.Length..].Trim(); + else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture)) + message = msg.Content[nickMention.Length..].Trim(); + else + return null; + + return message; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) + { + if (guild is not SocketGuild sg) + return false; + + try + { + var message = PrepareMessage(usrMsg, out var cbs); + if (message is null || cbs is null) + return false; + + var res = await _perms.CheckPermsAsync(sg, + usrMsg.Channel, + usrMsg.Author, + CleverBotResponseStr.CLEVERBOT_RESPONSE, + CleverBotResponseStr.CLEVERBOT_RESPONSE); + + if (!res.IsAllowed) + return false; + + var channel = (ITextChannel)usrMsg.Channel; + var conf = _ps.GetConfig(); + if (!_creds.IsOwner(sg.OwnerId) && conf.IsEnabled) + { + var quota = await _ps.TryGetFeatureLimitAsync(_flKey, sg.OwnerId, 0); + + uint? daily = quota.Quota is int dVal and < 0 + ? (uint)-dVal + : null; + + uint? monthly = quota.Quota is int mVal and >= 0 + ? (uint)mVal + : null; + + var maybeLimit = await _ps.TryIncrementQuotaCounterAsync(sg.OwnerId, + sg.OwnerId == usrMsg.Author.Id, + FeatureType.Limit, + _flKey.Key, + null, + daily, + monthly); + + if (maybeLimit.TryPickT1(out var ql, out var counters)) + { + if (ql.Quota == 0) + { + await _sender.Response(channel) + .Error(null, + text: + "In order to use the cleverbot feature, the owner of this server should be [Patron Tier X](https://patreon.com/join/elliebot) on patreon.", + footer: + "You may disable the cleverbot feature, and this message via '.cleverbot' command") + .SendAsync(); + + return true; + } + + await _sender.Response(channel) + .Error( + null!, + $"You've reached your quota limit of **{ql.Quota}** responses {ql.QuotaPeriod.ToFullName()} for the cleverbot feature.", + footer: "You may wait for the quota reset or .") + .SendAsync(); + + return true; + } + } + + _ = channel.TriggerTypingAsync(); + var response = await cbs.Think(message, usrMsg.Author.ToString()); + await _sender.Response(channel) + .Confirm(response) + .SendAsync(); + + Log.Information(""" + CleverBot Executed + Server: {GuildName} [{GuildId}] + Channel: {ChannelName} [{ChannelId}] + UserId: {Author} [{AuthorId}] + Message: {Content} + """, + guild.Name, + guild.Id, + usrMsg.Channel?.Name, + usrMsg.Channel?.Id, + usrMsg.Author, + usrMsg.Author.Id, + usrMsg.Content); + + return true; + } + catch (Exception ex) + { + Log.Warning(ex, "Error in cleverbot"); + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs b/src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs new file mode 100644 index 0000000..1a41953 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs @@ -0,0 +1,48 @@ +#nullable disable +using EllieBot.Db; +using EllieBot.Modules.Games.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class ChatterBotCommands : EllieModule + { + private readonly DbService _db; + + public ChatterBotCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task Cleverbot() + { + var channel = (ITextChannel)ctx.Channel; + + if (_service.ChatterBotGuilds.TryRemove(channel.Guild.Id, out _)) + { + await using (var uow = _db.GetDbContext()) + { + uow.Set().SetCleverbotEnabled(ctx.Guild.Id, false); + await uow.SaveChangesAsync(); + } + + await Response().Confirm(strs.cleverbot_disabled).SendAsync(); + return; + } + + _service.ChatterBotGuilds.TryAdd(channel.Guild.Id, new(() => _service.CreateSession(), true)); + + await using (var uow = _db.GetDbContext()) + { + uow.Set().SetCleverbotEnabled(ctx.Guild.Id, true); + await uow.SaveChangesAsync(); + } + + await Response().Confirm(strs.cleverbot_enabled).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs new file mode 100644 index 0000000..2f83164 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class CleverbotResponse +{ + public string Cs { get; set; } + public string Output { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/Gpt3Response.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/Gpt3Response.cs new file mode 100644 index 0000000..ad8692a --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/Gpt3Response.cs @@ -0,0 +1,46 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class Gpt3Response +{ + [JsonPropertyName("choices")] + public Choice[] Choices { get; set; } +} + +public class Choice +{ + [JsonPropertyName("message")] + public Message Message { get; init; } +} + +public class Message { + [JsonPropertyName("content")] + public string Content { get; init; } +} + +public class Gpt3ApiRequest +{ + [JsonPropertyName("model")] + public string Model { get; init; } + + [JsonPropertyName("messages")] + public List Messages { get; init; } + + [JsonPropertyName("temperature")] + public int Temperature { get; init; } + + [JsonPropertyName("max_tokens")] + public int MaxTokens { get; init; } +} + +public class GPTMessage +{ + [JsonPropertyName("role")] + public string Role {get; init;} + [JsonPropertyName("content")] + public string Content {get; init;} + [JsonPropertyName("name")] + public string Name {get; init;} +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs new file mode 100644 index 0000000..847d661 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public interface IChatterBotSession +{ + Task Think(string input, string username); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs new file mode 100644 index 0000000..83dc060 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs @@ -0,0 +1,38 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OfficialCleverbotSession : IChatterBotSession +{ + private string QueryString + => $"https://www.cleverbot.com/getreply?key={_apiKey}" + "&wrapper=elliebot" + "&input={0}" + "&cs={1}"; + + private readonly string _apiKey; + private readonly IHttpClientFactory _httpFactory; + private string cs; + + public OfficialCleverbotSession(string apiKey, IHttpClientFactory factory) + { + _apiKey = apiKey; + _httpFactory = factory; + } + + public async Task Think(string input, string username) + { + using var http = _httpFactory.CreateClient(); + var dataString = await http.GetStringAsync(string.Format(QueryString, input, cs ?? "")); + try + { + var data = JsonConvert.DeserializeObject(dataString); + + cs = data?.Cs; + return data?.Output; + } + catch + { + Log.Warning("Unexpected cleverbot response received: {ResponseString}", dataString); + return null; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialGpt3Session.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialGpt3Session.cs new file mode 100644 index 0000000..4711fd6 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialGpt3Session.cs @@ -0,0 +1,105 @@ +#nullable disable +using Newtonsoft.Json; +using System.Net.Http.Json; +using SharpToken; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OfficialGpt3Session : IChatterBotSession +{ + private string Uri + => $"https://api.openai.com/v1/chat/completions"; + + private readonly string _apiKey; + private readonly string _model; + private readonly int _maxHistory; + private readonly int _maxTokens; + private readonly int _minTokens; + private readonly string _ellieUsername; + private readonly GptEncoding _encoding; + private List messages = new(); + private readonly IHttpClientFactory _httpFactory; + + + + public OfficialGpt3Session( + string apiKey, + ChatGptModel model, + int chatHistory, + int maxTokens, + int minTokens, + string personality, + string ellieUsername, + IHttpClientFactory factory) + { + _apiKey = apiKey; + _httpFactory = factory; + switch (model) + { + case ChatGptModel.Gpt35Turbo: + _model = "gpt-3.5-turbo"; + break; + case ChatGptModel.Gpt4: + _model = "gpt-4"; + break; + case ChatGptModel.Gpt432k: + _model = "gpt-4-32k"; + break; + } + _maxHistory = chatHistory; + _maxTokens = maxTokens; + _minTokens = minTokens; + _ellieUsername = ellieUsername; + _encoding = GptEncoding.GetEncodingForModel(_model); + messages.Add(new GPTMessage(){Role = "user", Content = personality, Name = _ellieUsername}); + } + + public async Task Think(string input, string username) + { + messages.Add(new GPTMessage(){Role = "user", Content = input, Name = username}); + while(messages.Count > _maxHistory + 2){ + messages.RemoveAt(1); + } + int tokensUsed = 0; + foreach(GPTMessage message in messages){ + tokensUsed += _encoding.Encode(message.Content).Count; + } + tokensUsed *= 2; //Unsure why this is the case, but the token count chatgpt reports back is double what I calculate. + //check if we have the minimum number of tokens available to use. Remove messages until we have enough, otherwise exit out and inform the user why. + while(_maxTokens - tokensUsed <= _minTokens){ + if(messages.Count > 2){ + int tokens = _encoding.Encode(messages[1].Content).Count * 2; + tokensUsed -= tokens; + messages.RemoveAt(1); + } + else{ + return "Token count exceeded, please increase the number of tokens in the bot config and restart."; + } + } + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", _apiKey); + var data = await http.PostAsJsonAsync(Uri, new Gpt3ApiRequest() + { + Model = _model, + Messages = messages, + MaxTokens = _maxTokens - tokensUsed, + Temperature = 1, + }); + var dataString = await data.Content.ReadAsStringAsync(); + try + { + var response = JsonConvert.DeserializeObject(dataString); + string message = response?.Choices[0]?.Message?.Content; + //Can't rely on the return to except, now that we need to add it to the messages list. + _ = message ?? throw new ArgumentNullException(nameof(message)); + messages.Add(new GPTMessage(){Role = "assistant", Content = message, Name = _ellieUsername}); + return message; + } + catch + { + Log.Warning("Unexpected GPT-3 response received: {ResponseString}", dataString); + return null; + } + } +} + diff --git a/src/EllieBot/Modules/Games/Games.cs b/src/EllieBot/Modules/Games/Games.cs new file mode 100644 index 0000000..c14d6ee --- /dev/null +++ b/src/EllieBot/Modules/Games/Games.cs @@ -0,0 +1,47 @@ +#nullable disable +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +/* more games +- Shiritori +- Simple RPG adventure +*/ +public partial class Games : EllieModule +{ + private readonly IImageCache _images; + private readonly IHttpClientFactory _httpFactory; + private readonly Random _rng = new(); + + public Games(IImageCache images, IHttpClientFactory factory) + { + _images = images; + _httpFactory = factory; + } + + [Cmd] + public async Task Choose([Leftover] string list = null) + { + if (string.IsNullOrWhiteSpace(list)) + return; + var listArr = list.Split(';'); + if (listArr.Length < 2) + return; + var rng = new EllieRandom(); + await Response().Confirm("🤔", listArr[rng.Next(0, listArr.Length)]).SendAsync(); + } + + [Cmd] + public async Task EightBall([Leftover] string question = null) + { + if (string.IsNullOrWhiteSpace(question)) + return; + + var res = _service.GetEightballResponse(ctx.User.Id, question); + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithDescription(ctx.User.ToString()) + .AddField("❓ " + GetText(strs.question), question) + .AddField("🎱 " + GetText(strs._8ball), res)).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesConfig.cs b/src/EllieBot/Modules/Games/GamesConfig.cs new file mode 100644 index 0000000..1502c39 --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesConfig.cs @@ -0,0 +1,174 @@ +#nullable disable +using Cloneable; +using EllieBot.Common.Yml; + +namespace EllieBot.Modules.Games.Common; + +[Cloneable] +public sealed partial class GamesConfig : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 3; + + [Comment("Hangman related settings (.hangman command)")] + public HangmanConfig Hangman { get; set; } = new() + { + CurrencyReward = 0 + }; + + [Comment("Trivia related settings (.t command)")] + public TriviaConfig Trivia { get; set; } = new() + { + CurrencyReward = 0, + MinimumWinReq = 1 + }; + + [Comment("List of responses for the .8ball command. A random one will be selected every time")] + public List EightBallResponses { get; set; } = + [ + "Most definitely yes.", + "For sure.", + "Totally!", + "Of course!", + "As I see it, yes.", + "My sources say yes.", + "Yes.", + "Most likely.", + "Perhaps...", + "Maybe...", + "Hm, not sure.", + "It is uncertain.", + "Ask me again later.", + "Don't count on it.", + "Probably not.", + "Very doubtful.", + "Most likely no.", + "Nope.", + "No.", + "My sources say no.", + "Don't even think about it.", + "Definitely no.", + "NO - It may cause disease contraction!" + ]; + + [Comment("List of animals which will be used for the animal race game (.race)")] + public List RaceAnimals { get; set; } = + [ + new() + { + Icon = "🐼", + Name = "Panda" + }, + + new() + { + Icon = "🐻", + Name = "Bear" + }, + + new() + { + Icon = "🐧", + Name = "Pengu" + }, + + new() + { + Icon = "🐨", + Name = "Koala" + }, + + new() + { + Icon = "🐬", + Name = "Dolphin" + }, + + new() + { + Icon = "🐞", + Name = "Ladybird" + }, + + new() + { + Icon = "🦀", + Name = "Crab" + }, + + new() + { + Icon = "🦄", + Name = "Unicorn" + } + ]; + + [Comment(@"Which chatbot API should bot use. +'cleverbot' - bot will use Cleverbot API. +'gpt3' - bot will use GPT-3 API")] + public ChatBotImplementation ChatBot { get; set; } = ChatBotImplementation.Gpt3; + + public ChatGptConfig ChatGpt { get; set; } = new(); +} + +[Cloneable] +public sealed partial class ChatGptConfig +{ + [Comment(@"Which GPT-3 Model should bot use. + gpt35turbo - cheapest + gpt4 - 30x more expensive, higher quality + gp432k - same model as above, but with a 32k token limit")] + public ChatGptModel ModelName { get; set; } = ChatGptModel.Gpt35Turbo; + + [Comment(@"How should the chat bot behave, what's its personality? (Usage of this counts towards the max tokens)")] + public string PersonalityPrompt { get; set; } = "You are a chat bot willing to have a conversation with anyone about anything."; + + [Comment(@"The maximum number of messages in a conversation that can be remembered. (This will increase the number of tokens used)")] + public int ChatHistory { get; set; } = 5; + + [Comment(@"The maximum number of tokens to use per GPT-3 API call")] + public int MaxTokens { get; set; } = 100; + + [Comment(@"The minimum number of tokens to use per GPT-3 API call, such that chat history is removed to make room.")] + public int MinTokens { get; set; } = 30; +} + +[Cloneable] +public sealed partial class HangmanConfig +{ + [Comment("The amount of currency awarded to the winner of a hangman game")] + public long CurrencyReward { get; set; } +} + +[Cloneable] +public sealed partial class TriviaConfig +{ + [Comment("The amount of currency awarded to the winner of the trivia game.")] + public long CurrencyReward { get; set; } + + [Comment(""" + Users won't be able to start trivia games which have + a smaller win requirement than the one specified by this setting. + """)] + public int MinimumWinReq { get; set; } = 1; +} + +[Cloneable] +public sealed partial class RaceAnimal +{ + public string Icon { get; set; } + public string Name { get; set; } +} + +public enum ChatBotImplementation +{ + Cleverbot, + Gpt3 +} + +public enum ChatGptModel +{ + Gpt35Turbo, + Gpt4, + Gpt432k +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesConfigService.cs b/src/EllieBot/Modules/Games/GamesConfigService.cs new file mode 100644 index 0000000..6446c23 --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesConfigService.cs @@ -0,0 +1,94 @@ +#nullable disable +using EllieBot.Common.Configs; +using EllieBot.Modules.Games.Common; + +namespace EllieBot.Modules.Games.Services; + +public sealed class GamesConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/games.yml"; + private static readonly TypedKey _changeKey = new("config.games.updated"); + public override string Name { get; } = "games"; + + public GamesConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("trivia.min_win_req", + gs => gs.Trivia.MinimumWinReq, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + AddParsedProp("trivia.currency_reward", + gs => gs.Trivia.CurrencyReward, + long.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + AddParsedProp("hangman.currency_reward", + gs => gs.Hangman.CurrencyReward, + long.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + AddParsedProp("chatbot", + gs => gs.ChatBot, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + AddParsedProp("gpt.modelName", + gs => gs.ChatGpt.ModelName, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + AddParsedProp("gpt.personality", + gs => gs.ChatGpt.PersonalityPrompt, + ConfigParsers.String, + ConfigPrinters.ToString); + AddParsedProp("gpt.chathistory", + gs => gs.ChatGpt.ChatHistory, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + AddParsedProp("gpt.max_tokens", + gs => gs.ChatGpt.MaxTokens, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + AddParsedProp("gpt.min_tokens", + gs => gs.ChatGpt.MinTokens, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 1) + { + ModifyConfig(c => + { + c.Version = 1; + c.Hangman = new() + { + CurrencyReward = 0 + }; + }); + } + + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Version = 2; + c.ChatBot = ChatBotImplementation.Cleverbot; + }); + } + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + c.ChatGpt.ModelName = ChatGptModel.Gpt35Turbo; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesService.cs b/src/EllieBot/Modules/Games/GamesService.cs new file mode 100644 index 0000000..9f4f61b --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesService.cs @@ -0,0 +1,118 @@ +#nullable disable +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Games.Common; +using EllieBot.Modules.Games.Common.Acrophobia; +using EllieBot.Modules.Games.Common.Nunchi; +using Newtonsoft.Json; + +namespace EllieBot.Modules.Games.Services; + +public class GamesService : IEService, IReadyExecutor +{ + private const string TYPING_ARTICLES_PATH = "data/typing_articles3.json"; + + public ConcurrentDictionary GirlRatings { get; } = new(); + + public IReadOnlyList EightBallResponses + => _gamesConfig.Data.EightBallResponses; + + public List TypingArticles { get; } = new(); + + //channelId, game + public ConcurrentDictionary AcrophobiaGames { get; } = new(); + public Dictionary TicTacToeGames { get; } = new(); + public ConcurrentDictionary RunningContests { get; } = new(); + public ConcurrentDictionary NunchiGames { get; } = new(); + + public AsyncLazy Ratings { get; } + private readonly GamesConfigService _gamesConfig; + + private readonly IHttpClientFactory _httpFactory; + private readonly IMemoryCache _8BallCache; + private readonly Random _rng; + + public GamesService(GamesConfigService gamesConfig, IHttpClientFactory httpFactory) + { + _gamesConfig = gamesConfig; + _httpFactory = httpFactory; + _8BallCache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = 500_000 + }); + + Ratings = new(GetRatingTexts); + _rng = new EllieRandom(); + + try + { + TypingArticles = JsonConvert.DeserializeObject>(File.ReadAllText(TYPING_ARTICLES_PATH)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error while loading typing articles: {ErrorMessage}", ex.Message); + TypingArticles = new(); + } + } + + public async Task OnReadyAsync() + { + // reset rating once a day + using var timer = new PeriodicTimer(TimeSpan.FromDays(1)); + while (await timer.WaitForNextTickAsync()) + GirlRatings.Clear(); + } + + private async Task GetRatingTexts() + { + using var http = _httpFactory.CreateClient(); + var text = await http.GetStringAsync( + "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/rategirl/rates.json"); + return JsonConvert.DeserializeObject(text); + } + + public void AddTypingArticle(IUser user, string text) + { + TypingArticles.Add(new() + { + Source = user.ToString(), + Extra = $"Text added on {DateTime.UtcNow} by {user}.", + Text = text.SanitizeMentions(true) + }); + + File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(TypingArticles)); + } + + public string GetEightballResponse(ulong userId, string question) + => _8BallCache.GetOrCreate($"8ball:{userId}:{question}", + e => + { + e.Size = question.Length; + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12); + return EightBallResponses[_rng.Next(0, EightBallResponses.Count)]; + }); + + public TypingArticle RemoveTypingArticle(int index) + { + var articles = TypingArticles; + if (index < 0 || index >= articles.Count) + return null; + + var removed = articles[index]; + TypingArticles.RemoveAt(index); + + File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(articles)); + return removed; + } + + public class RatingTexts + { + public string Nog { get; set; } + public string Tra { get; set; } + public string Fun { get; set; } + public string Uni { get; set; } + public string Wif { get; set; } + public string Dat { get; set; } + public string Dan { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GirlRating.cs b/src/EllieBot/Modules/Games/GirlRating.cs new file mode 100644 index 0000000..4576216 --- /dev/null +++ b/src/EllieBot/Modules/Games/GirlRating.cs @@ -0,0 +1,61 @@ +#nullable disable +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Games.Common; + +public class GirlRating +{ + public double Crazy { get; } + public double Hot { get; } + public int Roll { get; } + public string Advice { get; } + + public AsyncLazy Stream { get; } + private readonly IImageCache _images; + + public GirlRating( + IImageCache images, + double crazy, + double hot, + int roll, + string advice) + { + _images = images; + Crazy = crazy; + Hot = hot; + Roll = roll; + Advice = advice; // convenient to have it here, even though atm there are only few different ones. + + Stream = new(async () => + { + try + { + var bgBytes = await _images.GetRategirlBgAsync(); + using var img = Image.Load(bgBytes); + const int minx = 35; + const int miny = 385; + const int length = 345; + + var pointx = (int)(minx + (length * (Hot / 10))); + var pointy = (int)(miny - (length * ((Crazy - 4) / 6))); + + var dotBytes = await _images.GetRategirlDotAsync(); + using (var pointImg = Image.Load(dotBytes)) + { + img.Mutate(x => x.DrawImage(pointImg, new(pointx - 10, pointy - 10), new GraphicsOptions())); + } + + var imgStream = new MemoryStream(); + img.SaveAsPng(imgStream); + return imgStream; + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting RateGirl image"); + return null; + } + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs b/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs new file mode 100644 index 0000000..333e8f0 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs @@ -0,0 +1,64 @@ +using EllieBot.Common.Yml; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public sealed class DefaultHangmanSource : IHangmanSource +{ + private IReadOnlyDictionary termsDict = new Dictionary(); + private readonly Random _rng; + + public DefaultHangmanSource() + { + _rng = new EllieRandom(); + Reload(); + } + + public void Reload() + { + if (!Directory.Exists("data/hangman")) + { + Log.Error("Hangman game won't work. Folder 'data/hangman' is missing"); + return; + } + + var qs = new Dictionary(); + foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml")) + { + try + { + var data = Yaml.Deserializer.Deserialize(File.ReadAllText(file)); + qs[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = data; + } + catch (Exception ex) + { + Log.Error(ex, "Loading {HangmanFile} failed", file); + } + } + + termsDict = qs; + + Log.Information("Loaded {HangmanCategoryCount} hangman categories", qs.Count); + } + + public IReadOnlyCollection GetCategories() + => termsDict.Keys.ToList(); + + public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term) + { + if (category is null) + { + var cats = GetCategories(); + category = cats.ElementAt(_rng.Next(0, cats.Count)); + } + + if (termsDict.TryGetValue(category, out var terms)) + { + term = terms[_rng.Next(0, terms.Length)]; + return true; + } + + term = null; + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs b/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs new file mode 100644 index 0000000..acc0323 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs @@ -0,0 +1,76 @@ +using EllieBot.Modules.Games.Hangman; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class HangmanCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Hangmanlist() + => await Response().Confirm(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n')).SendAsync(); + + private static string Draw(HangmanGame.State state) + => $""" + . ┌─────┐ + .┃...............┋ + .┃...............┋ + .┃{(state.Errors > 0 ? ".............😲" : "")} + .┃{(state.Errors > 1 ? "............./" : "")} {(state.Errors > 2 ? "|" : "")} {(state.Errors > 3 ? "\\" : "")} + .┃{(state.Errors > 4 ? "............../" : "")} {(state.Errors > 5 ? "\\" : "")} + /-\ + """; + + public static EmbedBuilder GetEmbed(IMessageSenderService sender, HangmanGame.State state) + { + if (state.Phase == HangmanGame.Phase.Running) + { + return sender.CreateEmbed() + .WithOkColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + if (state.Phase == HangmanGame.Phase.Ended && state.Failed) + { + return sender.CreateEmbed() + .WithErrorColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + return sender.CreateEmbed() + .WithOkColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Hangman([Leftover] string? type = null) + { + if (!_service.StartHangman(ctx.Channel.Id, type, out var hangman)) + { + await Response().Error(strs.hangman_running).SendAsync(); + return; + } + + var eb = GetEmbed(_sender, hangman); + eb.WithDescription(GetText(strs.hangman_game_started)); + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task HangmanStop() + { + if (await _service.StopHangman(ctx.Channel.Id)) + await Response().Confirm(strs.hangman_stopped).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs b/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs new file mode 100644 index 0000000..1625b55 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs @@ -0,0 +1,111 @@ +#nullable disable +namespace EllieBot.Modules.Games.Hangman; + +public sealed class HangmanGame +{ + public enum GuessResult { NoAction, AlreadyTried, Incorrect, Guess, Win } + + public enum Phase { Running, Ended } + + private Phase CurrentPhase { get; set; } + + private readonly HashSet _incorrect = new(); + private readonly HashSet _correct = new(); + private readonly HashSet _remaining = new(); + + private readonly string _word; + private readonly string _imageUrl; + + public HangmanGame(HangmanTerm term) + { + _word = term.Word; + _imageUrl = term.ImageUrl; + + _remaining = _word.ToLowerInvariant().Where(x => char.IsLetter(x)).Select(char.ToLowerInvariant).ToHashSet(); + } + + public State GetState(GuessResult guessResult = GuessResult.NoAction) + => new(_incorrect.Count, + CurrentPhase, + CurrentPhase == Phase.Ended ? _word : GetScrambledWord(), + guessResult, + _incorrect.ToList(), + CurrentPhase == Phase.Ended ? _imageUrl : string.Empty); + + private string GetScrambledWord() + { + Span output = stackalloc char[_word.Length * 2]; + for (var i = 0; i < _word.Length; i++) + { + var ch = _word[i]; + if (ch == ' ') + output[i * 2] = ' '; + if (!char.IsLetter(ch) || !_remaining.Contains(char.ToLowerInvariant(ch))) + output[i * 2] = ch; + else + output[i * 2] = '_'; + + output[(i * 2) + 1] = ' '; + } + + return new(output); + } + + public State Guess(string guess) + { + if (CurrentPhase != Phase.Running) + return GetState(); + + guess = guess.Trim(); + if (guess.Length > 1) + { + if (guess.Equals(_word, StringComparison.InvariantCultureIgnoreCase)) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Win); + } + + return GetState(); + } + + var charGuess = guess[0]; + if (!char.IsLetter(charGuess)) + return GetState(); + + if (_incorrect.Contains(charGuess) || _correct.Contains(charGuess)) + return GetState(GuessResult.AlreadyTried); + + if (_remaining.Remove(charGuess)) + { + if (_remaining.Count == 0) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Win); + } + + _correct.Add(charGuess); + return GetState(GuessResult.Guess); + } + + _incorrect.Add(charGuess); + if (_incorrect.Count > 5) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Incorrect); + } + + return GetState(GuessResult.Incorrect); + } + + public record State( + int Errors, + Phase Phase, + string Word, + GuessResult GuessResult, + List MissedLetters, + string ImageUrl) + { + public bool Failed + => Errors > 5; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanService.cs b/src/EllieBot/Modules/Games/Hangman/HangmanService.cs new file mode 100644 index 0000000..0b5c9c0 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanService.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Games.Services; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public sealed class HangmanService : IHangmanService, IExecNoCommand +{ + private readonly ConcurrentDictionary _hangmanGames = new(); + private readonly IHangmanSource _source; + private readonly IMessageSenderService _sender; + private readonly GamesConfigService _gcs; + private readonly ICurrencyService _cs; + private readonly IMemoryCache _cdCache; + private readonly object _locker = new(); + + public HangmanService( + IHangmanSource source, + IMessageSenderService sender, + GamesConfigService gcs, + ICurrencyService cs, + IMemoryCache cdCache) + { + _source = source; + _sender = sender; + _gcs = gcs; + _cs = cs; + _cdCache = cdCache; + } + + public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state) + { + state = null; + if (!_source.GetTerm(category, out var term)) + return false; + + + var game = new HangmanGame(term); + lock (_locker) + { + var hc = _hangmanGames.GetOrAdd(channelId, game); + if (hc == game) + { + state = hc.GetState(); + return true; + } + + return false; + } + } + + public ValueTask StopHangman(ulong channelId) + { + lock (_locker) + { + if (_hangmanGames.TryRemove(channelId, out _)) + return new(true); + } + + return new(false); + } + + public IReadOnlyCollection GetHangmanTypes() + => _source.GetCategories(); + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (_hangmanGames.ContainsKey(msg.Channel.Id)) + { + if (string.IsNullOrWhiteSpace(msg.Content)) + return; + + if (_cdCache.TryGetValue(msg.Author.Id, out _)) + return; + + HangmanGame.State state; + long rew = 0; + lock (_locker) + { + if (!_hangmanGames.TryGetValue(msg.Channel.Id, out var game)) + return; + + state = game.Guess(msg.Content.ToLowerInvariant()); + + if (state.GuessResult == HangmanGame.GuessResult.NoAction) + return; + + if (state.GuessResult is HangmanGame.GuessResult.Incorrect or HangmanGame.GuessResult.AlreadyTried) + { + _cdCache.Set(msg.Author.Id, + string.Empty, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3) + }); + } + + if (state.Phase == HangmanGame.Phase.Ended) + { + if (_hangmanGames.TryRemove(msg.Channel.Id, out _)) + rew = _gcs.Data.Hangman.CurrencyReward; + } + } + + if (rew > 0) + await _cs.AddAsync(msg.Author, rew, new("hangman", "win")); + + await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state); + } + } + + private Task SendState( + ITextChannel channel, + IUser user, + string content, + HangmanGame.State state) + { + var embed = Games.HangmanCommands.GetEmbed(_sender, state); + if (state.GuessResult == HangmanGame.GuessResult.Guess) + embed.WithDescription($"{user} guessed the letter {content}!").WithOkColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Incorrect && state.Failed) + embed.WithDescription($"{user} Letter {content} doesn't exist! Game over!").WithErrorColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Incorrect) + embed.WithDescription($"{user} Letter {content} doesn't exist!").WithErrorColor(); + else if (state.GuessResult == HangmanGame.GuessResult.AlreadyTried) + embed.WithDescription($"{user} Letter {content} has already been used.").WithPendingColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Win) + embed.WithDescription($"{user} won!").WithOkColor(); + + if (!string.IsNullOrWhiteSpace(state.ImageUrl) && Uri.IsWellFormedUriString(state.ImageUrl, UriKind.Absolute)) + embed.WithImageUrl(state.ImageUrl); + + return _sender.Response(channel).Embed(embed).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs b/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs new file mode 100644 index 0000000..22e5144 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Modules.Games.Hangman; + +public sealed class HangmanTerm +{ + public string Word { get; set; } + public string ImageUrl { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs b/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs new file mode 100644 index 0000000..da8d027 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public interface IHangmanService +{ + bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? hangmanController); + ValueTask StopHangman(ulong channelId); + IReadOnlyCollection GetHangmanTypes(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs b/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs new file mode 100644 index 0000000..d28199b --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public interface IHangmanSource : IEService +{ + public IReadOnlyCollection GetCategories(); + public void Reload(); + public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs b/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs new file mode 100644 index 0000000..6fa579c --- /dev/null +++ b/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs @@ -0,0 +1,183 @@ +#nullable disable +using System.Collections.Immutable; + +namespace EllieBot.Modules.Games.Common.Nunchi; + +public sealed class NunchiGame : IDisposable +{ + public enum Phase + { + Joining, + Playing, + WaitingForNextRound, + Ended + } + + private const int KILL_TIMEOUT = 20 * 1000; + private const int NEXT_ROUND_TIMEOUT = 5 * 1000; + + public event Func OnGameStarted; + public event Func OnRoundStarted; + public event Func OnUserGuessed; + public event Func OnRoundEnded; // tuple of the user who failed + public event Func OnGameEnded; // name of the user who won + + public int CurrentNumber { get; private set; } = new EllieRandom().Next(0, 100); + public Phase CurrentPhase { get; private set; } = Phase.Joining; + + public ImmutableArray<(ulong Id, string Name)> Participants + => participants.ToImmutableArray(); + + public int ParticipantCount + => participants.Count; + + private readonly SemaphoreSlim _locker = new(1, 1); + + private HashSet<(ulong Id, string Name)> participants = []; + private readonly HashSet<(ulong Id, string Name)> _passed = []; + private Timer killTimer; + + public NunchiGame(ulong creatorId, string creatorName) + => participants.Add((creatorId, creatorName)); + + public async Task Join(ulong userId, string userName) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Joining) + return false; + + return participants.Add((userId, userName)); + } + finally { _locker.Release(); } + } + + public async Task Initialize() + { + CurrentPhase = Phase.Joining; + await Task.Delay(30000); + await _locker.WaitAsync(); + try + { + if (participants.Count < 3) + { + CurrentPhase = Phase.Ended; + return false; + } + + killTimer = new(async _ => + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Playing) + return; + + //if some players took too long to type a number, boot them all out and start a new round + participants = new HashSet<(ulong, string)>(_passed); + EndRound(); + } + finally { _locker.Release(); } + }, + null, + KILL_TIMEOUT, + KILL_TIMEOUT); + + CurrentPhase = Phase.Playing; + _ = OnGameStarted?.Invoke(this); + _ = OnRoundStarted?.Invoke(this, CurrentNumber); + return true; + } + finally { _locker.Release(); } + } + + public async Task Input(ulong userId, string userName, int input) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Playing) + return; + + var userTuple = (Id: userId, Name: userName); + + // if the user is not a member of the race, + // or he already successfully typed the number + // ignore the input + if (!participants.Contains(userTuple) || !_passed.Add(userTuple)) + return; + + //if the number is correct + if (CurrentNumber == input - 1) + { + //increment current number + ++CurrentNumber; + if (_passed.Count == participants.Count - 1) + { + // if only n players are left, and n - 1 type the correct number, round is over + + // if only 2 players are left, game is over + if (participants.Count == 2) + { + killTimer.Change(Timeout.Infinite, Timeout.Infinite); + CurrentPhase = Phase.Ended; + _ = OnGameEnded?.Invoke(this, userTuple.Name); + } + else // else just start the new round without the user who was the last + { + var failure = participants.Except(_passed).First(); + + OnUserGuessed?.Invoke(this); + EndRound(failure); + return; + } + } + + OnUserGuessed?.Invoke(this); + } + else + { + //if the user failed + + EndRound(userTuple); + } + } + finally { _locker.Release(); } + } + + private void EndRound((ulong, string)? failure = null) + { + killTimer.Change(KILL_TIMEOUT, KILL_TIMEOUT); + CurrentNumber = new EllieRandom().Next(0, 100); // reset the counter + _passed.Clear(); // reset all users who passed (new round starts) + if (failure is not null) + participants.Remove(failure.Value); // remove the dude who failed from the list of players + + _ = OnRoundEnded?.Invoke(this, failure); + if (participants.Count <= 1) // means we have a winner or everyone was booted out + { + killTimer.Change(Timeout.Infinite, Timeout.Infinite); + CurrentPhase = Phase.Ended; + _ = OnGameEnded?.Invoke(this, participants.Count > 0 ? participants.First().Name : null); + return; + } + + CurrentPhase = Phase.WaitingForNextRound; + Task.Run(async () => + { + await Task.Delay(NEXT_ROUND_TIMEOUT); + CurrentPhase = Phase.Playing; + _ = OnRoundStarted?.Invoke(this, CurrentNumber); + }); + } + + public void Dispose() + { + OnGameEnded = null; + OnGameStarted = null; + OnRoundEnded = null; + OnRoundStarted = null; + OnUserGuessed = null; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs b/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs new file mode 100644 index 0000000..80e6c42 --- /dev/null +++ b/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs @@ -0,0 +1,114 @@ +#nullable disable +using EllieBot.Modules.Games.Common.Nunchi; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class NunchiCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public NunchiCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Nunchi() + { + var newNunchi = new NunchiGame(ctx.User.Id, ctx.User.ToString()); + NunchiGame nunchi; + + //if a game was already active + if ((nunchi = _service.NunchiGames.GetOrAdd(ctx.Guild.Id, newNunchi)) != newNunchi) + { + // join it + // if you failed joining, that means game is running or just ended + if (!await nunchi.Join(ctx.User.Id, ctx.User.ToString())) + return; + + await Response().Error(strs.nunchi_joined(nunchi.ParticipantCount)).SendAsync(); + return; + } + + + try { await Response().Confirm(strs.nunchi_created).SendAsync(); } + catch { } + + nunchi.OnGameEnded += NunchiOnGameEnded; + //nunchi.OnGameStarted += Nunchi_OnGameStarted; + nunchi.OnRoundEnded += Nunchi_OnRoundEnded; + nunchi.OnUserGuessed += Nunchi_OnUserGuessed; + nunchi.OnRoundStarted += Nunchi_OnRoundStarted; + _client.MessageReceived += ClientMessageReceived; + + var success = await nunchi.Initialize(); + if (!success) + { + if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game)) + game.Dispose(); + await Response().Confirm(strs.nunchi_failed_to_start).SendAsync(); + } + + Task ClientMessageReceived(SocketMessage arg) + { + _ = Task.Run(async () => + { + if (arg.Channel.Id != ctx.Channel.Id) + return; + + if (!int.TryParse(arg.Content, out var number)) + return; + try + { + await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number); + } + catch + { + } + }); + return Task.CompletedTask; + } + + Task NunchiOnGameEnded(NunchiGame arg1, string arg2) + { + if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game)) + { + _client.MessageReceived -= ClientMessageReceived; + game.Dispose(); + } + + if (arg2 is null) + return Response().Confirm(strs.nunchi_ended_no_winner).SendAsync(); + return Response().Confirm(strs.nunchi_ended(Format.Bold(arg2))).SendAsync(); + } + } + + private Task Nunchi_OnRoundStarted(NunchiGame arg, int cur) + => Response() + .Confirm(strs.nunchi_round_started(Format.Bold(arg.ParticipantCount.ToString()), + Format.Bold(cur.ToString()))) + .SendAsync(); + + private Task Nunchi_OnUserGuessed(NunchiGame arg) + => Response().Confirm(strs.nunchi_next_number(Format.Bold(arg.CurrentNumber.ToString()))).SendAsync(); + + private Task Nunchi_OnRoundEnded(NunchiGame arg1, (ulong Id, string Name)? arg2) + { + if (arg2.HasValue) + return Response().Confirm(strs.nunchi_round_ended(Format.Bold(arg2.Value.Name))).SendAsync(); + return Response() + .Confirm(strs.nunchi_round_ended_boot( + Format.Bold("\n" + + string.Join("\n, ", + arg1.Participants.Select(x + => x.Name))))) + .SendAsync(); // this won't work if there are too many users + } + + private Task Nunchi_OnGameStarted(NunchiGame arg) + => Response().Confirm(strs.nunchi_started(Format.Bold(arg.ParticipantCount.ToString()))).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs b/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs new file mode 100644 index 0000000..65b8bbd --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs @@ -0,0 +1,105 @@ +#nullable disable +using EllieBot.Modules.Games.Common; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class SpeedTypingCommands : EllieModule + { + private readonly GamesService _games; + private readonly DiscordSocketClient _client; + + public SpeedTypingCommands(DiscordSocketClient client, GamesService games) + { + _games = games; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task TypeStart(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new TypingGame.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + var game = _service.RunningContests.GetOrAdd(ctx.Guild.Id, + _ => new(_games, _client, channel, prefix, options, _sender)); + + if (game.IsActive) + await Response().Error($"Contest already running in {game.Channel.Mention} channel.").SendAsync(); + else + await game.Start(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task TypeStop() + { + if (_service.RunningContests.TryRemove(ctx.Guild.Id, out var game)) + { + await game.Stop(); + return; + } + + await Response().Error("No contest to stop on this channel.").SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task Typeadd([Leftover] string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + _games.AddTypingArticle(ctx.User, text); + + await Response().Confirm("Added new article for typing game.").SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Typelist(int page = 1) + { + if (page < 1) + return; + + var articles = _games.TypingArticles.Skip((page - 1) * 15).Take(15).ToArray(); + + if (!articles.Any()) + { + await Response().Error($"{ctx.User.Mention} `No articles found on that page.`").SendAsync(); + return; + } + + var i = (page - 1) * 15; + await Response() + .Confirm("List of articles for Type Race", + string.Join("\n", articles.Select(a => $"`#{++i}` - {a.Text.TrimTo(50)}"))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task Typedel(int index) + { + var removed = _service.RemoveTypingArticle(--index); + + if (removed is null) + return; + + var embed = _sender.CreateEmbed() + .WithTitle($"Removed typing article #{index + 1}") + .WithDescription(removed.Text.TrimTo(50)) + .WithOkColor(); + + await Response().Embed(embed).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs b/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs new file mode 100644 index 0000000..cb55893 --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common; + +public class TypingArticle +{ + public string Source { get; set; } + public string Extra { get; set; } + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs b/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs new file mode 100644 index 0000000..d712525 --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs @@ -0,0 +1,197 @@ +#nullable disable +using CommandLine; +using EllieBot.Modules.Games.Services; +using System.Diagnostics; + +namespace EllieBot.Modules.Games.Common; + +public class TypingGame +{ + public const float WORD_VALUE = 4.5f; + public ITextChannel Channel { get; } + public string CurrentSentence { get; private set; } + public bool IsActive { get; private set; } + private readonly Stopwatch _sw; + private readonly List _finishedUserIds; + private readonly DiscordSocketClient _client; + private readonly GamesService _games; + private readonly string _prefix; + private readonly Options _options; + private readonly IMessageSenderService _sender; + + public TypingGame( + GamesService games, + DiscordSocketClient client, + ITextChannel channel, + string prefix, + Options options, + IMessageSenderService sender) + { + _games = games; + _client = client; + _prefix = prefix; + _options = options; + _sender = sender; + + Channel = channel; + IsActive = false; + _sw = new(); + _finishedUserIds = new(); + } + + public async Task Stop() + { + if (!IsActive) + return false; + _client.MessageReceived -= AnswerReceived; + _finishedUserIds.Clear(); + IsActive = false; + _sw.Stop(); + _sw.Reset(); + try + { + await _sender.Response(Channel) + .Confirm("Typing contest stopped.") + .SendAsync(); + } + catch + { + } + + return true; + } + + public async Task Start() + { + if (IsActive) + return; // can't start running game + IsActive = true; + CurrentSentence = GetRandomSentence(); + var i = (int)(CurrentSentence.Length / WORD_VALUE * 1.7f); + try + { + await _sender.Response(Channel) + .Confirm( + $":clock2: Next contest will last for {i} seconds. Type the bolded text as fast as you can.") + .SendAsync(); + + + var time = _options.StartTime; + + var msg = await _sender.Response(Channel).Confirm($"Starting new typing contest in **{time}**...").SendAsync(); + + do + { + await Task.Delay(2000); + time -= 2; + try { await msg.ModifyAsync(m => m.Content = $"Starting new typing contest in **{time}**.."); } + catch { } + } while (time > 2); + + await msg.ModifyAsync(m => + { + m.Content = CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture); + }); + _sw.Start(); + HandleAnswers(); + + while (i > 0) + { + await Task.Delay(1000); + i--; + if (!IsActive) + return; + } + } + catch { } + finally + { + await Stop(); + } + } + + public string GetRandomSentence() + { + if (_games.TypingArticles.Any()) + return _games.TypingArticles[new EllieRandom().Next(0, _games.TypingArticles.Count)].Text; + return $"No typing articles found. Use {_prefix}typeadd command to add a new article for typing."; + } + + private void HandleAnswers() + => _client.MessageReceived += AnswerReceived; + + private Task AnswerReceived(SocketMessage imsg) + { + _ = Task.Run(async () => + { + try + { + if (imsg.Author.IsBot) + return; + if (imsg is not SocketUserMessage msg) + return; + + if (Channel is null || Channel.Id != msg.Channel.Id) + return; + + var guess = msg.Content; + + var distance = CurrentSentence.LevenshteinDistance(guess); + var decision = Judge(distance, guess.Length); + if (decision && !_finishedUserIds.Contains(msg.Author.Id)) + { + var elapsed = _sw.Elapsed; + var wpm = CurrentSentence.Length / WORD_VALUE / elapsed.TotalSeconds * 60; + _finishedUserIds.Add(msg.Author.Id); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"{msg.Author} finished the race!") + .AddField("Place", $"#{_finishedUserIds.Count}", true) + .AddField("WPM", $"{wpm:F1} *[{elapsed.TotalSeconds:F2}sec]*", true) + .AddField("Errors", distance.ToString(), true); + + await _sender.Response(Channel) + .Embed(embed) + .SendAsync(); + + if (_finishedUserIds.Count % 4 == 0) + { + await _sender.Response(Channel) + .Confirm( + $""" + :exclamation: A lot of people finished, here is the text for those still typing: + + **{Format.Sanitize(CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture)).SanitizeMentions(true)}** + """) + .SendAsync(); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error receiving typing game answer: {ErrorMessage}", ex.Message); + } + }); + return Task.CompletedTask; + } + + private static bool Judge(int errors, int textLength) + => errors <= textLength / 25; + + public class Options : IEllieCommandOptions + { + [Option('s', + "start-time", + Default = 5, + Required = false, + HelpText = "How long does it take for the race to start. Default 5.")] + public int StartTime { get; set; } = 5; + + public void NormalizeOptions() + { + if (StartTime is < 3 or > 30) + StartTime = 5; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs b/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs new file mode 100644 index 0000000..fa8070d --- /dev/null +++ b/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs @@ -0,0 +1,307 @@ +#nullable disable +using CommandLine; +using System.Text; + +namespace EllieBot.Modules.Games.Common; + +public class TicTacToe +{ + public event Action OnEnded; + private readonly ITextChannel _channel; + private readonly IGuildUser[] _users; + private readonly int?[,] _state; + private Phase phase; + private int curUserIndex; + private readonly SemaphoreSlim _moveLock; + + private IGuildUser winner; + + private readonly string[] _numbers = + [ + ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:" + ]; + + private IUserMessage previousMessage; + private Timer timeoutTimer; + private readonly IBotStrings _strings; + private readonly DiscordSocketClient _client; + private readonly Options _options; + private readonly IMessageSenderService _sender; + + public TicTacToe( + IBotStrings strings, + DiscordSocketClient client, + ITextChannel channel, + IGuildUser firstUser, + Options options, + IMessageSenderService sender) + { + _channel = channel; + _strings = strings; + _client = client; + _options = options; + _sender = sender; + + _users = [firstUser, null]; + _state = new int?[,] { { null, null, null }, { null, null, null }, { null, null, null } }; + + phase = Phase.Starting; + _moveLock = new(1, 1); + } + + private string GetText(LocStr key) + => _strings.GetText(key, _channel.GuildId); + + public string GetState() + { + var sb = new StringBuilder(); + for (var i = 0; i < _state.GetLength(0); i++) + { + for (var j = 0; j < _state.GetLength(1); j++) + { + sb.Append(_state[i, j] is null ? _numbers[(i * 3) + j] : GetIcon(_state[i, j])); + if (j < _state.GetLength(1) - 1) + sb.Append("┃"); + } + + if (i < _state.GetLength(0) - 1) + sb.AppendLine("\n──────────"); + } + + return sb.ToString(); + } + + public EmbedBuilder GetEmbed(string title = null) + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(Environment.NewLine + GetState()) + .WithAuthor(GetText(strs.vs(_users[0], _users[1]))); + + if (!string.IsNullOrWhiteSpace(title)) + embed.WithTitle(title); + + if (winner is null) + { + if (phase == Phase.Ended) + embed.WithFooter(GetText(strs.ttt_no_moves)); + else + embed.WithFooter(GetText(strs.ttt_users_move(_users[curUserIndex]))); + } + else + embed.WithFooter(GetText(strs.ttt_has_won(winner))); + + return embed; + } + + private static string GetIcon(int? val) + { + switch (val) + { + case 0: + return "❌"; + case 1: + return "⭕"; + case 2: + return "❎"; + case 3: + return "🅾"; + default: + return "⬛"; + } + } + + public async Task Start(IGuildUser user) + { + if (phase is Phase.Started or Phase.Ended) + { + await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_already_running)).SendAsync(); + return; + } + + if (_users[0] == user) + { + await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_against_yourself)).SendAsync(); + return; + } + + _users[1] = user; + + phase = Phase.Started; + + timeoutTimer = new(async _ => + { + await _moveLock.WaitAsync(); + try + { + if (phase == Phase.Ended) + return; + + phase = Phase.Ended; + if (_users[1] is not null) + { + winner = _users[curUserIndex ^= 1]; + var del = previousMessage?.DeleteAsync(); + try + { + await _sender.Response(_channel).Embed(GetEmbed(GetText(strs.ttt_time_expired))).SendAsync(); + if (del is not null) + await del; + } + catch { } + } + + OnEnded?.Invoke(this); + } + catch { } + finally + { + _moveLock.Release(); + } + }, + null, + _options.TurnTimer * 1000, + Timeout.Infinite); + + _client.MessageReceived += Client_MessageReceived; + + + previousMessage = await _sender.Response(_channel).Embed(GetEmbed(GetText(strs.game_started))).SendAsync(); + } + + private bool IsDraw() + { + for (var i = 0; i < 3; i++) + for (var j = 0; j < 3; j++) + { + if (_state[i, j] is null) + return false; + } + + return true; + } + + private Task Client_MessageReceived(SocketMessage msg) + { + _ = Task.Run(async () => + { + await _moveLock.WaitAsync(); + try + { + var curUser = _users[curUserIndex]; + if (phase == Phase.Ended || msg.Author?.Id != curUser.Id) + return; + + if (int.TryParse(msg.Content, out var index) + && --index >= 0 + && index <= 9 + && _state[index / 3, index % 3] is null) + { + _state[index / 3, index % 3] = curUserIndex; + + // i'm lazy + if (_state[index / 3, 0] == _state[index / 3, 1] && _state[index / 3, 1] == _state[index / 3, 2]) + { + _state[index / 3, 0] = curUserIndex + 2; + _state[index / 3, 1] = curUserIndex + 2; + _state[index / 3, 2] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (_state[0, index % 3] == _state[1, index % 3] + && _state[1, index % 3] == _state[2, index % 3]) + { + _state[0, index % 3] = curUserIndex + 2; + _state[1, index % 3] = curUserIndex + 2; + _state[2, index % 3] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (curUserIndex == _state[0, 0] + && _state[0, 0] == _state[1, 1] + && _state[1, 1] == _state[2, 2]) + { + _state[0, 0] = curUserIndex + 2; + _state[1, 1] = curUserIndex + 2; + _state[2, 2] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (curUserIndex == _state[0, 2] + && _state[0, 2] == _state[1, 1] + && _state[1, 1] == _state[2, 0]) + { + _state[0, 2] = curUserIndex + 2; + _state[1, 1] = curUserIndex + 2; + _state[2, 0] = curUserIndex + 2; + + phase = Phase.Ended; + } + + var reason = string.Empty; + + if (phase == Phase.Ended) // if user won, stop receiving moves + { + reason = GetText(strs.ttt_matched_three); + winner = _users[curUserIndex]; + _client.MessageReceived -= Client_MessageReceived; + OnEnded?.Invoke(this); + } + else if (IsDraw()) + { + reason = GetText(strs.ttt_a_draw); + phase = Phase.Ended; + _client.MessageReceived -= Client_MessageReceived; + OnEnded?.Invoke(this); + } + + _ = Task.Run(async () => + { + var del1 = msg.DeleteAsync(); + var del2 = previousMessage?.DeleteAsync(); + try { previousMessage = await _sender.Response(_channel).Embed(GetEmbed(reason)).SendAsync(); } + catch { } + + try { await del1; } + catch { } + + try + { + if (del2 is not null) + await del2; + } + catch { } + }); + curUserIndex ^= 1; + + timeoutTimer.Change(_options.TurnTimer * 1000, Timeout.Infinite); + } + } + finally + { + _moveLock.Release(); + } + }); + + return Task.CompletedTask; + } + + public class Options : IEllieCommandOptions + { + [Option('t', "turn-timer", Required = false, Default = 15, HelpText = "Turn time in seconds. Default 15.")] + public int TurnTimer { get; set; } = 15; + + public void NormalizeOptions() + { + if (TurnTimer is < 5 or > 60) + TurnTimer = 15; + } + } + + private enum Phase + { + Starting, + Started, + Ended + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs b/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs new file mode 100644 index 0000000..904f8db --- /dev/null +++ b/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs @@ -0,0 +1,54 @@ +#nullable disable +using EllieBot.Modules.Games.Common; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class TicTacToeCommands : EllieModule + { + private readonly SemaphoreSlim _sem = new(1, 1); + private readonly DiscordSocketClient _client; + + public TicTacToeCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task TicTacToe(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new TicTacToe.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + await _sem.WaitAsync(1000); + try + { + if (_service.TicTacToeGames.TryGetValue(channel.Id, out var game)) + { + _ = Task.Run(async () => + { + await game.Start((IGuildUser)ctx.User); + }); + return; + } + + game = new(Strings, _client, channel, (IGuildUser)ctx.User, options, _sender); + _service.TicTacToeGames.Add(channel.Id, game); + await Response().Confirm(strs.ttt_created(ctx.User)).SendAsync(); + + game.OnEnded += _ => + { + _service.TicTacToeGames.Remove(channel.Id); + _sem.Dispose(); + }; + } + finally + { + _sem.Release(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/Games.cs b/src/EllieBot/Modules/Games/Trivia/Games.cs new file mode 100644 index 0000000..ff7ffbd --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/Games.cs @@ -0,0 +1,282 @@ +using System.Net; +using System.Text; +using EllieBot.Modules.Games.Common.Trivia; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class TriviaCommands : EllieModule + { + private readonly ILocalDataCache _cache; + private readonly ICurrencyService _cs; + private readonly GamesConfigService _gamesConfig; + private readonly DiscordSocketClient _client; + + public TriviaCommands( + DiscordSocketClient client, + ILocalDataCache cache, + ICurrencyService cs, + GamesConfigService gamesConfig) + { + _cache = cache; + _cs = cs; + _gamesConfig = gamesConfig; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + [EllieOptions] + public async Task Trivia(params string[] args) + { + var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args); + + var config = _gamesConfig.Data; + if (opts.WinRequirement != 0 + && config.Trivia.MinimumWinReq > 0 + && config.Trivia.MinimumWinReq > opts.WinRequirement) + return; + + var trivia = new TriviaGame(opts, _cache); + if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia)) + { + RegisterEvents(trivia); + await trivia.RunAsync(); + return; + } + + if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg)) + { + await Response().Error(strs.trivia_already_running).SendAsync(); + await tg.TriggerQuestionAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Tl() + { + if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia)) + { + await trivia.TriggerStatsAsync(); + return; + } + + await Response().Error(strs.trivia_none).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Tq() + { + var channel = (ITextChannel)ctx.Channel; + + if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia)) + { + if (trivia.Stop()) + { + try + { + await Response() + .Confirm(GetText(strs.trivia_game), GetText(strs.trivia_stopping)) + .SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error sending trivia stopping message"); + } + } + + return; + } + + await Response().Error(strs.trivia_none).SendAsync(); + } + + private string GetLeaderboardString(TriviaGame tg) + { + var sb = new StringBuilder(); + + foreach (var (id, pts) in tg.GetLeaderboard()) + sb.AppendLine(GetText(strs.trivia_points(Format.Bold($"<@{id}>"), pts))); + + return sb.ToString(); + } + + private EmbedBuilder? questionEmbed = null; + private IUserMessage? questionMessage = null; + private bool showHowToQuit = false; + + private void RegisterEvents(TriviaGame trivia) + { + trivia.OnQuestion += OnTriviaQuestion; + trivia.OnHint += OnTriviaHint; + trivia.OnGuess += OnTriviaGuess; + trivia.OnEnded += OnTriviaEnded; + trivia.OnStats += OnTriviaStats; + trivia.OnTimeout += OnTriviaTimeout; + } + + private void UnregisterEvents(TriviaGame trivia) + { + trivia.OnQuestion -= OnTriviaQuestion; + trivia.OnHint -= OnTriviaHint; + trivia.OnGuess -= OnTriviaGuess; + trivia.OnEnded -= OnTriviaEnded; + trivia.OnStats -= OnTriviaStats; + trivia.OnTimeout -= OnTriviaTimeout; + } + + private async Task OnTriviaHint(TriviaGame game, TriviaQuestion question) + { + try + { + if (questionMessage is null) + { + game.Stop(); + return; + } + + if (questionEmbed is not null) + await questionMessage.ModifyAsync(m + => m.Embed = questionEmbed.WithFooter(question.GetHint()).Build()); + } + catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) + { + Log.Warning("Unable to edit message to show hint. Stopping trivia"); + game.Stop(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error editing trivia message"); + } + } + + private async Task OnTriviaQuestion(TriviaGame game, TriviaQuestion question) + { + try + { + questionEmbed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.trivia_game)) + .AddField(GetText(strs.category), question.Category) + .AddField(GetText(strs.question), question.Question); + + showHowToQuit = !showHowToQuit; + if (showHowToQuit) + questionEmbed.WithFooter(GetText(strs.trivia_quit($"{prefix}tq"))); + + if (Uri.IsWellFormedUriString(question.ImageUrl, UriKind.Absolute)) + questionEmbed.WithImageUrl(question.ImageUrl); + + questionMessage = await Response().Embed(questionEmbed).SendAsync(); + } + catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound + or HttpStatusCode.Forbidden + or HttpStatusCode.BadRequest) + { + Log.Warning("Unable to send trivia questions. Stopping immediately"); + game.Stop(); + throw; + } + } + + private async Task OnTriviaTimeout(TriviaGame _, TriviaQuestion question) + { + try + { + var embed = _sender.CreateEmbed() + .WithErrorColor() + .WithTitle(GetText(strs.trivia_game)) + .WithDescription(GetText(strs.trivia_times_up(Format.Bold(question.Answer)))); + + if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute)) + embed.WithImageUrl(question.AnswerImageUrl); + + await Response().Embed(embed).SendAsync(); + } + catch + { + // ignored + } + } + + private async Task OnTriviaStats(TriviaGame game) + { + try + { + await Response().Confirm(GetText(strs.leaderboard), GetLeaderboardString(game)).SendAsync(); + } + catch + { + // ignored + } + } + + private async Task OnTriviaEnded(TriviaGame game) + { + try + { + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(strs.trivia_ended)) + .WithTitle(GetText(strs.leaderboard)) + .WithDescription(GetLeaderboardString(game))).SendAsync(); + } + catch + { + // ignored + } + finally + { + _service.RunningTrivias.TryRemove(ctx.Guild.Id, out _); + } + + UnregisterEvents(game); + } + + private async Task OnTriviaGuess( + TriviaGame _, + TriviaUser user, + TriviaQuestion question, + bool isWin) + { + try + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.trivia_game)) + .WithDescription(GetText(strs.trivia_win(user.Name, + Format.Bold(question.Answer)))); + + if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute)) + embed.WithImageUrl(question.AnswerImageUrl); + + + if (isWin) + { + await Response().Embed(embed).SendAsync(); + + var reward = _gamesConfig.Data.Trivia.CurrencyReward; + if (reward > 0) + await _cs.AddAsync(user.Id, reward, new("trivia", "win")); + + return; + } + + embed.WithDescription(GetText(strs.trivia_guess(user.Name, + Format.Bold(question.Answer)))); + + await Response().Embed(embed).SendAsync(); + } + catch + { + // ignored + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs new file mode 100644 index 0000000..b82dd62 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public sealed class DefaultQuestionPool : IQuestionPool +{ + private readonly ILocalDataCache _cache; + private readonly EllieRandom _rng; + + public DefaultQuestionPool(ILocalDataCache cache) + { + _cache = cache; + _rng = new EllieRandom(); + } + public async Task GetQuestionAsync() + { + var pool = await _cache.GetTriviaQuestionsAsync(); + + if(pool is null or {Length: 0}) + return default; + + return new(pool[_rng.Next(0, pool.Length)]); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs new file mode 100644 index 0000000..636ae16 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public interface IQuestionPool +{ + Task GetQuestionAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs new file mode 100644 index 0000000..53f56cf --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs @@ -0,0 +1,32 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public sealed class PokemonQuestionPool : IQuestionPool +{ + public int QuestionsCount => 905; // xd + private readonly EllieRandom _rng; + private readonly ILocalDataCache _cache; + + public PokemonQuestionPool(ILocalDataCache cache) + { + _cache = cache; + _rng = new EllieRandom(); + } + + public async Task GetQuestionAsync() + { + var pokes = await _cache.GetPokemonMapAsync(); + + if (pokes is null or { Count: 0 }) + return default; + + var num = _rng.Next(1, QuestionsCount + 1); + return new(new() + { + Question = "Who's That Pokémon?", + Answer = pokes[num].ToTitleCase(), + Category = "Pokemon", + ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png", + AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png" + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs b/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs new file mode 100644 index 0000000..4223104 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs @@ -0,0 +1,219 @@ +using System.Threading.Channels; +using Exception = System.Exception; + +namespace EllieBot.Modules.Games.Common.Trivia; + +public sealed class TriviaGame +{ + private readonly TriviaOptions _opts; + + + private readonly IQuestionPool _questionPool; + + #region Events + public event Func OnQuestion = static delegate { return Task.CompletedTask; }; + public event Func OnHint = static delegate { return Task.CompletedTask; }; + public event Func OnStats = static delegate { return Task.CompletedTask; }; + public event Func OnGuess = static delegate { return Task.CompletedTask; }; + public event Func OnTimeout = static delegate { return Task.CompletedTask; }; + public event Func OnEnded = static delegate { return Task.CompletedTask; }; + #endregion + + private bool _isStopped; + + public TriviaQuestion? CurrentQuestion { get; set; } + + + private readonly ConcurrentDictionary _users = new (); + + private readonly Channel<(TriviaUser User, string Input)> _inputs + = Channel.CreateUnbounded<(TriviaUser, string)>(new UnboundedChannelOptions + { + AllowSynchronousContinuations = true, + SingleReader = true, + SingleWriter = false, + }); + + public TriviaGame(TriviaOptions options, ILocalDataCache cache) + { + _opts = options; + + _questionPool = _opts.IsPokemon + ? new PokemonQuestionPool(cache) + : new DefaultQuestionPool(cache); + + } + public async Task RunAsync() + { + await GameLoop(); + } + + private async Task GameLoop() + { + Task TimeOutFactory() => Task.Delay(_opts.QuestionTimer * 1000 / 2); + + var errorCount = 0; + var inactivity = 0; + + // loop until game is stopped + // each iteration is one round + var firstRun = true; + try + { + while (!_isStopped) + { + if (errorCount >= 5) + { + Log.Warning("Trivia errored 5 times and will quit"); + break; + } + + // wait for 3 seconds before posting the next question + if (firstRun) + { + firstRun = false; + } + else + { + await Task.Delay(3000); + } + + var maybeQuestion = await _questionPool.GetQuestionAsync(); + + if (maybeQuestion is not { } question) + { + // if question is null (ran out of question, or other bugg ) - stop + break; + } + + CurrentQuestion = question; + try + { + // clear out all of the past guesses + while (_inputs.Reader.TryRead(out _)) + ; + + await OnQuestion(this, question); + } + catch (Exception ex) + { + Log.Warning(ex, "Error executing OnQuestion: {Message}", ex.Message); + errorCount++; + continue; + } + + + // just keep looping through user inputs until someone guesses the answer + // or the timer expires + var halfGuessTimerTask = TimeOutFactory(); + var hintSent = false; + var guessed = false; + while (true) + { + using var readCancel = new CancellationTokenSource(); + var readTask = _inputs.Reader.ReadAsync(readCancel.Token).AsTask(); + + // wait for either someone to attempt to guess + // or for timeout + var task = await Task.WhenAny(readTask, halfGuessTimerTask); + + // if the task which completed is the timeout task + if (task == halfGuessTimerTask) + { + readCancel.Cancel(); + + // if hint is already sent, means time expired + // break (end the round) + if (hintSent) + break; + + // else, means half time passed, send a hint + hintSent = true; + // start a new countdown of the same length + halfGuessTimerTask = TimeOutFactory(); + if (!_opts.NoHint) + { + // send a hint out + await OnHint(this, question); + } + + continue; + } + + // otherwise, read task is successful, and we're gonna + // get the user input data + var (user, input) = await readTask; + + // check the guess + if (question.IsAnswerCorrect(input)) + { + // add 1 point to the user + var val = _users.AddOrUpdate(user.Id, 1, (_, points) => ++points); + guessed = true; + + // reset inactivity counter + inactivity = 0; + errorCount = 0; + + var isWin = false; + // if user won the game, tell the game to stop + if (_opts.WinRequirement != 0 && val >= _opts.WinRequirement) + { + _isStopped = true; + isWin = true; + } + + // call onguess + await OnGuess(this, user, question, isWin); + break; + } + } + + if (!guessed) + { + await OnTimeout(this, question); + + if (_opts.Timeout != 0 && ++inactivity >= _opts.Timeout) + { + Log.Information("Trivia game is stopping due to inactivity"); + break; + } + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Fatal error in trivia game: {ErrorMessage}", ex.Message); + } + finally + { + // make sure game is set as ended + _isStopped = true; + _ = OnEnded(this); + } + } + + public IReadOnlyList<(ulong User, int points)> GetLeaderboard() + => _users.Select(x => (x.Key, x.Value)).ToArray(); + + public ValueTask InputAsync(TriviaUser user, string input) + => _inputs.Writer.WriteAsync((user, input)); + + public bool Stop() + { + var isStopped = _isStopped; + _isStopped = true; + return !isStopped; + } + + public async ValueTask TriggerStatsAsync() + { + await OnStats(this); + } + + public async Task TriggerQuestionAsync() + { + if(CurrentQuestion is TriviaQuestion q) + await OnQuestion(this, q); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs b/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs new file mode 100644 index 0000000..6fc4ab6 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs @@ -0,0 +1,37 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Games.Common.Trivia; + +namespace EllieBot.Modules.Games; + +public sealed class TriviaGamesService : IReadyExecutor, IEService +{ + private readonly DiscordSocketClient _client; + public ConcurrentDictionary RunningTrivias { get; } = new(); + + public TriviaGamesService(DiscordSocketClient client) + { + _client = client; + } + + public Task OnReadyAsync() + { + _client.MessageReceived += OnMessageReceived; + + return Task.CompletedTask; + } + + private async Task OnMessageReceived(SocketMessage msg) + { + if (msg.Author.IsBot) + return; + + var umsg = msg as SocketUserMessage; + + if (umsg?.Channel is not IGuildChannel gc) + return; + + if (RunningTrivias.TryGetValue(gc.GuildId, out var tg)) + await tg.InputAsync(new(umsg.Author.Mention, umsg.Author.Id), umsg.Content); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs b/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs new file mode 100644 index 0000000..47bdc0b --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs @@ -0,0 +1,44 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Games.Common.Trivia; + +public class TriviaOptions : IEllieCommandOptions +{ + [Option('p', "pokemon", Required = false, Default = false, HelpText = "Whether it's 'Who's that pokemon?' trivia.")] + public bool IsPokemon { get; set; } = false; + + [Option("nohint", Required = false, Default = false, HelpText = "Don't show any hints.")] + public bool NoHint { get; set; } = false; + + [Option('w', + "win-req", + Required = false, + Default = 10, + HelpText = "Winning requirement. Set 0 for an infinite game. Default 10.")] + public int WinRequirement { get; set; } = 10; + + [Option('q', + "question-timer", + Required = false, + Default = 30, + HelpText = "How long until the question ends. Default 30.")] + public int QuestionTimer { get; set; } = 30; + + [Option('t', + "timeout", + Required = false, + Default = 10, + HelpText = "Number of questions of inactivity in order stop. Set 0 for never. Default 10.")] + public int Timeout { get; set; } = 10; + + public void NormalizeOptions() + { + if (WinRequirement < 0) + WinRequirement = 10; + if (QuestionTimer is < 10 or > 300) + QuestionTimer = 30; + if (Timeout is < 0 or > 20) + Timeout = 10; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs b/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs new file mode 100644 index 0000000..299c762 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs @@ -0,0 +1,115 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Games.Common.Trivia; + +public class TriviaQuestion +{ + public const int MAX_STRING_LENGTH = 22; + + //represents the min size to judge levDistance with + private static readonly HashSet> _strictness = + [ + new(9, 0), + new(14, 1), + new(19, 2), + new(22, 3) + ]; + + public string Category + => _qModel.Category; + + public string Question + => _qModel.Question; + + public string ImageUrl + => _qModel.ImageUrl; + + public string AnswerImageUrl + => _qModel.AnswerImageUrl ?? ImageUrl; + + public string Answer + => _qModel.Answer; + + public string CleanAnswer + => cleanAnswer ?? (cleanAnswer = Clean(Answer)); + + private string cleanAnswer; + private readonly TriviaQuestionModel _qModel; + + public TriviaQuestion(TriviaQuestionModel qModel) + { + _qModel = qModel; + } + + public string GetHint() + => Scramble(Answer); + + public bool IsAnswerCorrect(string guess) + { + if (Answer.Equals(guess, StringComparison.InvariantCulture)) + return true; + var cleanGuess = Clean(guess); + if (CleanAnswer.Equals(cleanGuess, StringComparison.InvariantCulture)) + return true; + + var levDistanceClean = CleanAnswer.LevenshteinDistance(cleanGuess); + var levDistanceNormal = Answer.LevenshteinDistance(guess); + return JudgeGuess(CleanAnswer.Length, cleanGuess.Length, levDistanceClean) + || JudgeGuess(Answer.Length, guess.Length, levDistanceNormal); + } + + private static bool JudgeGuess(int guessLength, int answerLength, int levDistance) + { + foreach (var level in _strictness) + { + if (guessLength <= level.Item1 || answerLength <= level.Item1) + { + if (levDistance <= level.Item2) + return true; + return false; + } + } + + return false; + } + + private static string Clean(string str) + { + str = " " + str.ToLowerInvariant() + " "; + str = Regex.Replace(str, @"\s+", " "); + str = Regex.Replace(str, @"[^\w\d\s]", ""); + //Here's where custom modification can be done + str = Regex.Replace(str, @"\s(a|an|the|of|in|for|to|as|at|be)\s", " "); + //End custom mod and cleanup whitespace + str = Regex.Replace(str, @"^\s+", ""); + str = Regex.Replace(str, @"\s+$", ""); + //Trim the really long answers + str = str.Length <= MAX_STRING_LENGTH ? str : str[..MAX_STRING_LENGTH]; + return str; + } + + private static string Scramble(string word) + { + var letters = word.ToCharArray(); + var count = 0; + for (var i = 0; i < letters.Length; i++) + { + if (letters[i] == ' ') + continue; + + count++; + if (count <= letters.Length / 5) + continue; + + if (count % 3 == 0) + continue; + + if (letters[i] != ' ') + letters[i] = '_'; + } + + return string.Join(" ", + new string(letters).Replace(" ", " \u2000", StringComparison.InvariantCulture).AsEnumerable()); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs b/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs new file mode 100644 index 0000000..b61e827 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs @@ -0,0 +1,3 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public record class TriviaUser(string Name, ulong Id); \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/CommandJsonObject.cs b/src/EllieBot/Modules/Help/CommandJsonObject.cs new file mode 100644 index 0000000..062a0b9 --- /dev/null +++ b/src/EllieBot/Modules/Help/CommandJsonObject.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Modules.Help; + +internal class CommandJsonObject +{ + public string[] Aliases { get; set; } + public string Description { get; set; } + public string[] Usage { get; set; } + public string Submodule { get; set; } + public string Module { get; set; } + public List Options { get; set; } + public string[] Requirements { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/CommandsOptions.cs b/src/EllieBot/Modules/Help/CommandsOptions.cs new file mode 100644 index 0000000..ecbb06c --- /dev/null +++ b/src/EllieBot/Modules/Help/CommandsOptions.cs @@ -0,0 +1,26 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Help.Common; + +public class CommandsOptions : IEllieCommandOptions +{ + public enum ViewType + { + Hide, + Cross, + All + } + + [Option('v', + "view", + Required = false, + Default = ViewType.Hide, + HelpText = + "Specifies how to output the list of commands. 0 - Hide commands which you can't use, 1 - Cross out commands which you can't use, 2 - Show all.")] + public ViewType View { get; set; } = ViewType.Hide; + + public void NormalizeOptions() + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/Help.cs b/src/EllieBot/Modules/Help/Help.cs new file mode 100644 index 0000000..79367b9 --- /dev/null +++ b/src/EllieBot/Modules/Help/Help.cs @@ -0,0 +1,593 @@ +#nullable disable +using EllieBot.Modules.Help.Common; +using EllieBot.Modules.Help.Services; +using Newtonsoft.Json; +using System.Text; +using Ellie.Common.Marmalade; + +namespace EllieBot.Modules.Help; + +public sealed partial class Help : EllieModule +{ + public const string PATREON_URL = "https://patreon.com/toastie_t0ast"; + public const string PAYPAL_URL = "https://paypal.me/EmotionChild"; + + private readonly ICommandsUtilityService _cus; + private readonly CommandService _cmds; + private readonly BotConfigService _bss; + private readonly IPermissionChecker _perms; + private readonly IServiceProvider _services; + private readonly DiscordSocketClient _client; + private readonly IBotStrings _strings; + + private readonly AsyncLazy _lazyClientId; + private readonly IMarmaladeLoaderService _marmalades; + + public Help( + ICommandsUtilityService _cus, + IPermissionChecker perms, + CommandService cmds, + BotConfigService bss, + IServiceProvider services, + DiscordSocketClient client, + IBotStrings strings, + IMarmaladeLoaderService marmalades) + { + this._cus = _cus; + _cmds = cmds; + _bss = bss; + _perms = perms; + _services = services; + _client = client; + _strings = strings; + _marmalades = marmalades; + + _lazyClientId = new(async () => (await _client.GetApplicationInfoAsync()).Id); + } + + public async Task GetHelpString() + { + var botSettings = _bss.Data; + if (string.IsNullOrWhiteSpace(botSettings.HelpText) || botSettings.HelpText == "-") + return default; + + var clientId = await _lazyClientId.Value; + var repCtx = new ReplacementContext(Context) + .WithOverride("{0}", () => clientId.ToString()) + .WithOverride("{1}", () => prefix) + .WithOverride("%prefix%", () => prefix) + .WithOverride("%bot.prefix%", () => prefix); + + var text = SmartText.CreateFrom(botSettings.HelpText); + return await repSvc.ReplaceAsync(text, repCtx); + } + + [Cmd] + public async Task Modules(int page = 1) + { + if (--page < 0) + return; + + var topLevelModules = new List(); + foreach (var m in _cmds.Modules.GroupBy(x => x.GetTopLevelModule()).OrderBy(x => x.Key.Name).Select(x => x.Key)) + { + var result = await _perms.CheckPermsAsync(ctx.Guild, + ctx.Channel, + ctx.User, + m.Name, + null); + +#if GLOBAL_ELLIE + if (m.Preconditions.Any(x => x is NoPublicBotAttribute)) + continue; +#endif + + if (result.IsAllowed) + topLevelModules.Add(m); + } + + var menu = new SelectMenuBuilder() + .WithPlaceholder("Select a module to see its commands") + .WithCustomId("cmds:modules_select"); + + foreach (var m in topLevelModules) + menu.AddOption(m.Name, m.Name, GetModuleEmoji(m.Name)); + + var inter = _inter.Create(ctx.User.Id, + menu, + async (smc) => + { + await smc.DeferAsync(); + var val = smc.Data.Values.FirstOrDefault(); + if (val is null) + return; + + await Commands(val); + }); + + await Response() + .Paginated() + .Items(topLevelModules) + .PageSize(12) + .CurrentPage(page) + .Interaction(inter) + .AddFooter(false) + .Page((items, _) => + { + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.list_of_modules)); + + if (!items.Any()) + { + embed = embed.WithOkColor().WithDescription(GetText(strs.module_page_empty)); + return embed; + } + + items + .ToList() + .ForEach(module => embed.AddField($"{GetModuleEmoji(module.Name)} {module.Name}", + GetModuleDescription(module.Name) + + "\n" + + Format.Code(GetText(strs.module_footer(prefix, module.Name.ToLowerInvariant()))), + true)); + + return embed; + }) + .SendAsync(); + } + + private string GetModuleDescription(string moduleName) + { + var key = GetModuleLocStr(moduleName); + + if (key.Key == strs.module_description_missing.Key) + { + var desc = _marmalades + .GetLoadedMarmalades(Culture) + .FirstOrDefault(m => m.Canaries + .Any(x => x.Name.Equals(moduleName, + StringComparison.InvariantCultureIgnoreCase))) + ?.Description; + + if (desc is not null) + return desc; + } + + return GetText(key); + } + + private LocStr GetModuleLocStr(string moduleName) + { + switch (moduleName.ToLowerInvariant()) + { + case "help": + return strs.module_description_help; + case "administration": + return strs.module_description_administration; + case "expressions": + return strs.module_description_expressions; + case "searches": + return strs.module_description_searches; + case "utility": + return strs.module_description_utility; + case "games": + return strs.module_description_games; + case "gambling": + return strs.module_description_gambling; + case "music": + return strs.module_description_music; + case "nsfw": + return strs.module_description_nsfw; + case "permissions": + return strs.module_description_permissions; + case "xp": + return strs.module_description_xp; + case "marmalade": + return strs.module_description_marmalade; + case "patronage": + return strs.module_description_patronage; + default: + return strs.module_description_missing; + } + } + + private string GetModuleEmoji(string moduleName) + { + moduleName = moduleName.ToLowerInvariant(); + switch (moduleName) + { + case "help": + return "❓"; + case "administration": + return "🛠️"; + case "expressions": + return "🗣️"; + case "searches": + return "🔍"; + case "utility": + return "🔧"; + case "games": + return "🎲"; + case "gambling": + return "💰"; + case "music": + return "🎶"; + case "nsfw": + return "😳"; + case "permissions": + return "🚓"; + case "xp": + return "📝"; + case "patronage": + return "💝"; + default: + return "📖"; + } + } + + [Cmd] + [EllieOptions] + public async Task Commands(string module = null, params string[] args) + { + if (string.IsNullOrWhiteSpace(module)) + { + await Modules(); + return; + } + + var (opts, _) = OptionsParser.ParseFrom(new CommandsOptions(), args); + + // Find commands for that module + // don't show commands which are blocked + // order by name + var allowed = new List(); + + var mdls = _cmds.Commands + .Where(c => c.Module.GetTopLevelModule() + .Name + .StartsWith(module, StringComparison.InvariantCultureIgnoreCase)) + .ToArray(); + + if (mdls.Length == 0) + { + var group = _cmds.Modules + .Where(x => x.Parent is not null) + .FirstOrDefault(x => string.Equals(x.Name.Replace("Commands", ""), + module, + StringComparison.InvariantCultureIgnoreCase)); + + if (group is not null) + { + await Group(group); + return; + } + } + + foreach (var cmd in mdls) + { + var result = await _perms.CheckPermsAsync(ctx.Guild, + ctx.Channel, + ctx.User, + cmd.Module.GetTopLevelModule().Name, + cmd.Name); + + if (result.IsAllowed) + allowed.Add(cmd); + } + + + var cmds = allowed.OrderBy(c => c.Aliases[0]) + .DistinctBy(x => x.Aliases[0]) + .ToList(); + + + // check preconditions for all commands, but only if it's not 'all' + // because all will show all commands anyway, no need to check + var succ = new HashSet(); + if (opts.View != CommandsOptions.ViewType.All) + { + succ = + [ + ..(await cmds.Select(async x => + { + var pre = await x.CheckPreconditionsAsync(Context, _services); + return (Cmd: x, Succ: pre.IsSuccess); + }) + .WhenAll()).Where(x => x.Succ) + .Select(x => x.Cmd) + ]; + + if (opts.View == CommandsOptions.ViewType.Hide) + // if hidden is specified, completely remove these commands from the list + cmds = cmds.Where(x => succ.Contains(x)).ToList(); + } + + var cmdsWithGroup = cmds.GroupBy(c => c.Module.GetGroupName()) + .OrderBy(x => x.Key == x.First().Module.Name ? int.MaxValue : x.Count()) + .ToList(); + + if (cmdsWithGroup.Count == 0) + { + if (opts.View != CommandsOptions.ViewType.Hide) + await Response().Error(strs.module_not_found).SendAsync(); + else + await Response().Error(strs.module_not_found_or_cant_exec).SendAsync(); + return; + } + + var sb = new SelectMenuBuilder() + .WithCustomId("cmds:submodule_select") + .WithPlaceholder("Select a submodule to see detailed commands"); + + var groups = cmdsWithGroup.ToArray(); + var embed = _sender.CreateEmbed().WithOkColor(); + foreach (var g in groups) + { + sb.AddOption(g.Key, g.Key); + var transformed = g + .Select(x => + { + //if cross is specified, and the command doesn't satisfy the requirements, cross it out + if (opts.View == CommandsOptions.ViewType.Cross) + { + return $"{(succ.Contains(x) ? "✅" : "❌")} {prefix + x.Aliases[0]}"; + } + + + if (x.Aliases.Count == 1) + return prefix + x.Aliases[0]; + + return prefix + x.Aliases[0] + " | " + prefix + x.Aliases[1]; + }); + + embed.AddField(g.Key, "" + string.Join("\n", transformed) + "", true); + } + + embed.WithFooter(GetText(strs.commands_instr(prefix))); + + + var inter = _inter.Create(ctx.User.Id, + sb, + async (smc) => + { + var groupName = smc.Data.Values.FirstOrDefault(); + var mdl = _cmds.Modules.FirstOrDefault(x + => string.Equals(x.Name.Replace("Commands", ""), groupName, StringComparison.InvariantCultureIgnoreCase)); + await smc.DeferAsync(); + await Group(mdl); + } + ); + + await Response().Embed(embed).Interaction(inter).SendAsync(); + } + + private async Task Group(ModuleInfo group) + { + var menu = new SelectMenuBuilder() + .WithCustomId("cmds:group_select") + .WithPlaceholder("Select a command to see its details"); + + foreach (var cmd in group.Commands.DistinctBy(x => x.Aliases[0])) + { + menu.AddOption(prefix + cmd.Aliases[0], cmd.Aliases[0]); + } + + var inter = _inter.Create(ctx.User.Id, + menu, + async (smc) => + { + await smc.DeferAsync(); + + await H(smc.Data.Values.FirstOrDefault()); + }); + + await Response() + .Paginated() + .Items(group.Commands.DistinctBy(x => x.Aliases[0]).ToArray()) + .PageSize(25) + .Interaction(inter) + .Page((items, _) => + { + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.cmd_group_commands(group.Name))) + .WithOkColor(); + + foreach (var cmd in items) + { + string cmdName; + if (cmd.Aliases.Count > 1) + cmdName = Format.Code(prefix + cmd.Aliases[0]) + " | " + Format.Code(prefix + cmd.Aliases[1]); + else + cmdName = Format.Code(prefix + cmd.Aliases.First()); + + eb.AddField(cmdName, cmd.RealSummary(_strings, _marmalades, Culture, prefix)); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [Priority(0)] + public async Task H([Leftover] string fail) + { + var prefixless = + _cmds.Commands.FirstOrDefault(x => x.Aliases.Any(cmdName => cmdName.ToLowerInvariant() == fail)); + if (prefixless is not null) + { + await H(prefixless); + return; + } + + if (fail.StartsWith(prefix)) + fail = fail.Substring(prefix.Length); + + var group = _cmds.Modules + .SelectMany(x => x.Submodules) + .FirstOrDefault(x => string.Equals(x.Group, + fail, + StringComparison.InvariantCultureIgnoreCase)); + + if (group is not null) + { + await Group(group); + return; + } + + await Response().Error(strs.command_not_found).SendAsync(); + } + + [Cmd] + [Priority(1)] + public async Task H([Leftover] CommandInfo com = null) + { + var channel = ctx.Channel; + if (com is null) + { + try + { + var ch = channel is ITextChannel ? await ctx.User.CreateDMChannelAsync() : channel; + var data = await GetHelpString(); + if (data == default) + return; + + await Response().Channel(ch).Text(data).SendAsync(); + try + { + await ctx.OkAsync(); + } + catch + { + } // ignore if bot can't react + } + catch (Exception) + { + await Response().Error(strs.cant_dm).SendAsync(); + } + + return; + } + + var embed = _cus.GetCommandHelp(com, ctx.Guild); + await _sender.Response(channel).Embed(embed).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GenCmdList() + { + _ = ctx.Channel.TriggerTypingAsync(); + + // order commands by top level module name + // and make a dictionary of > + var cmdData = _cmds.Commands.GroupBy(x => x.Module.GetTopLevelModule().Name) + .OrderBy(x => x.Key) + .ToDictionary(x => x.Key, + x => x.DistinctBy(c => c.Aliases.First()) + .Select(com => + { + List optHelpStr = null; + + var opt = CommandsUtilityService.GetEllieOptionType(com.Attributes); + if (opt is not null) + optHelpStr = CommandsUtilityService.GetCommandOptionHelpList(opt); + + return new CommandJsonObject + { + Aliases = com.Aliases.Select(alias => prefix + alias).ToArray(), + Description = com.RealSummary(_strings, _marmalades, Culture, prefix), + Usage = com.RealRemarksArr(_strings, _marmalades, Culture, prefix), + Submodule = com.Module.Name, + Module = com.Module.GetTopLevelModule().Name, + Options = optHelpStr, + Requirements = CommandsUtilityService.GetCommandRequirements(com) + }; + }) + .ToList()); + + var readableData = JsonConvert.SerializeObject(cmdData, Formatting.Indented); + + // send the indented file to chat + await using var rDataStream = new MemoryStream(Encoding.ASCII.GetBytes(readableData)); + await ctx.Channel.SendFileAsync(rDataStream, "cmds.json", GetText(strs.commandlist_regen)); + } + + [Cmd] + public async Task Guide() + => await Response() + .Confirm(strs.guide("https://commands.elliebot.net", + "https://docs.elliebot.net/")) + .SendAsync(); + + + private Task SelfhostAction(SocketMessageComponent smc) + => smc.RespondConfirmAsync(_sender, + """ + - In case you don't want or cannot Donate to EllieBot project, but you + - EllieBot is a free and [open source](https://toastielab.dev/Emotions-stuff/Ellie) project which means you can run your own "selfhosted" instance on your computer. + + *Keep in mind that running the bot on your computer means that the bot will be offline when you turn off your computer* + + - You can find the selfhosting guides by using the `.guide` command and clicking on the second link that pops up. + - If you decide to selfhost the bot, still consider [supporting the project](https://patreon.com/join/toastie_t0ast) to keep the development going :) + """, + true); + + [Cmd] + [OnlyPublicBot] + public async Task Donate() + { + var selfhostInter = _inter.Create(ctx.User.Id, + new ButtonBuilder( + emote: new Emoji("🖥️"), + customId: "donate:selfhosting", + label: "Selfhosting"), + SelfhostAction); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("Thank you for considering to donate to the EllieBot project!"); + + eb + .WithDescription(""" + EllieBot relies on donations to keep the servers, services and APIs running. + Donating will give you access to some exclusive features. You can read about them on the [patreon page](https://patreon.com/join/toastie_t0ast) + """) + .AddField("Donation Instructions", + $""" + 🗒️ Before pledging it is recommended to open your DMs as Ellie will send you a welcome message with instructions after you pledge has been processed and confirmed. + + **Step 1:** ❤️ Pledge on Patreon ❤️ + + `1.` Go to and choose a tier. + `2.` Make sure your payment is processed and accepted. + + **Step 2** 🤝 Connect your Discord account 🤝 + + `1.` Go to your profile settings on Patreon and connect your Discord account to it. + *please make sure you're logged into the correct Discord account* + + If you do not know how to do it, you may [follow instructions here](https://support.patreon.com/hc/en-us/articles/212052266-How-do-I-connect-Discord-to-Patreon-Patron-) + + **Step 3** ⏰ Wait a short while (usually 1-3 minutes) ⏰ + + Ellie will DM you the welcome instructions, and you will receive your rewards! + 🎉 **Enjoy!** 🎉 + """); + + try + { + await Response() + .Channel(await ctx.User.CreateDMChannelAsync()) + .Embed(eb) + .Interaction(selfhostInter) + .SendAsync(); + + _ = ctx.OkAsync(); + } + catch + { + await Response().Error(strs.cant_dm).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/HelpService.cs b/src/EllieBot/Modules/Help/HelpService.cs new file mode 100644 index 0000000..5904c03 --- /dev/null +++ b/src/EllieBot/Modules/Help/HelpService.cs @@ -0,0 +1,44 @@ +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Help.Services; + +public class HelpService : IExecNoCommand, IEService +{ + private readonly BotConfigService _bss; + private readonly IReplacementService _rs; + private readonly IMessageSenderService _sender; + + public HelpService(BotConfigService bss, IReplacementService repSvc, IMessageSenderService sender) + { + _bss = bss; + _rs = repSvc; + _sender = sender; + } + + public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + { + var settings = _bss.Data; + if (guild is null) + { + if (string.IsNullOrWhiteSpace(settings.DmHelpText) || settings.DmHelpText == "-") + return; + + // only send dm help text if it contains one of the keywords, if they're specified + // if they're not, then reply to every DM + if (settings.DmHelpTextKeywords is not null + && !settings.DmHelpTextKeywords.Any(k => msg.Content.Contains(k))) + { + return; + } + + var repCtx = new ReplacementContext(guild: guild, channel: msg.Channel, users: msg.Author) + .WithOverride("%prefix%", () => _bss.Data.Prefix) + .WithOverride("%bot.prefix%", () => _bss.Data.Prefix); + + var text = SmartText.CreateFrom(settings.DmHelpText); + text = await _rs.ReplaceAsync(text, repCtx); + + await _sender.Response(msg.Channel).Text(text).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Marmalade/IMarmaladesRepositoryService.cs b/src/EllieBot/Modules/Marmalade/IMarmaladesRepositoryService.cs new file mode 100644 index 0000000..155f56b --- /dev/null +++ b/src/EllieBot/Modules/Marmalade/IMarmaladesRepositoryService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules; + +public interface IMarmaladesRepositoryService +{ + Task> GetModuleItemsAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Marmalade/Marmalade.cs b/src/EllieBot/Modules/Marmalade/Marmalade.cs new file mode 100644 index 0000000..530b23f --- /dev/null +++ b/src/EllieBot/Modules/Marmalade/Marmalade.cs @@ -0,0 +1,243 @@ +using Ellie.Common.Marmalade; + +namespace EllieBot.Modules; + +[OwnerOnly] +[NoPublicBot] +public partial class Marmalade : EllieModule +{ + private readonly IMarmaladesRepositoryService _repo; + + public Marmalade(IMarmaladesRepositoryService repo) + { + _repo = repo; + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeLoad(string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + var loaded = _service.GetLoadedMarmalades() + .Select(x => x.Name) + .ToHashSet(); + + var unloaded = _service.GetAllMarmalades() + .Where(x => !loaded.Contains(x)) + .Select(x => Format.Code(x.ToString())) + .ToArray(); + + if (unloaded.Length == 0) + { + await Response().Pending(strs.no_marmalade_available).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(unloaded) + .PageSize(10) + .Page((items, _) => + { + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.list_of_unloaded)) + .WithDescription(items.Join('\n')); + }) + .SendAsync(); + return; + } + + var res = await _service.LoadMarmaladeAsync(name); + if (res == MarmaladeLoadResult.Success) + await Response().Confirm(strs.marmalade_loaded(Format.Code(name))).SendAsync(); + else + { + var locStr = res switch + { + MarmaladeLoadResult.Empty => strs.marmalade_empty, + MarmaladeLoadResult.AlreadyLoaded => strs.marmalade_already_loaded(Format.Code(name)), + MarmaladeLoadResult.NotFound => strs.marmalade_invalid_not_found, + MarmaladeLoadResult.UnknownError => strs.error_occured, + _ => strs.error_occured + }; + + await Response().Error(locStr).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeUnload(string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + var loaded = _service.GetLoadedMarmalades(); + if (loaded.Count == 0) + { + await Response().Pending(strs.no_marmalade_loaded).SendAsync(); + return; + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.loaded_marmalades)) + .WithDescription(loaded.Select(x => x.Name) + .Join("\n"))) + .SendAsync(); + + return; + } + + var res = await _service.UnloadMarmaladeAsync(name); + if (res == MarmaladeUnloadResult.Success) + await Response().Confirm(strs.marmalade_unloaded(Format.Code(name))).SendAsync(); + else + { + var locStr = res switch + { + MarmaladeUnloadResult.NotLoaded => strs.marmalade_not_loaded, + MarmaladeUnloadResult.PossiblyUnable => strs.marmalade_possibly_cant_unload, + _ => strs.error_occured + }; + + await Response().Error(locStr).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeList() + { + var all = _service.GetAllMarmalades(); + + if (all.Count == 0) + { + await Response().Pending(strs.no_marmalade_available).SendAsync(); + return; + } + + var loaded = _service.GetLoadedMarmalades() + .Select(x => x.Name) + .ToHashSet(); + + var output = all + .Select(m => + { + var emoji = loaded.Contains(m) ? "`✅`" : "`🔴`"; + return $"{emoji} `{m}`"; + }) + .ToArray(); + + + await Response() + .Paginated() + .Items(output) + .PageSize(10) + .Page((items, _) => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.list_of_marmalades)) + .WithDescription(items.Join('\n'))) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeInfo(string? name = null) + { + var marmalades = _service.GetLoadedMarmalades(); + + if (name is not null) + { + var found = marmalades.FirstOrDefault(x => string.Equals(x.Name, + name, + StringComparison.InvariantCultureIgnoreCase)); + + if (found is null) + { + await Response().Error(strs.marmalade_name_not_found).SendAsync(); + return; + } + + var cmdCount = found.Canaries.Sum(x => x.Commands.Count); + var cmdNames = found.Canaries + .SelectMany(x => Format.Code(string.IsNullOrWhiteSpace(x.Prefix) + ? x.Name + : $"{x.Prefix} {x.Name}")) + .Join("\n"); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(strs.marmalade_info)) + .WithTitle(found.Name) + .WithDescription(found.Description) + .AddField(GetText(strs.canaries_count(found.Canaries.Count)), + found.Canaries.Count == 0 + ? "-" + : found.Canaries.Select(x => x.Name).Join('\n'), + true) + .AddField(GetText(strs.commands_count(cmdCount)), + string.IsNullOrWhiteSpace(cmdNames) + ? "-" + : cmdNames, + true); + + await Response().Embed(eb).SendAsync(); + return; + } + + if (marmalades.Count == 0) + { + await Response().Pending(strs.no_marmalade_loaded).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(marmalades) + .PageSize(9) + .CurrentPage(0) + .Page((items, _) => + { + var eb = _sender.CreateEmbed() + .WithOkColor(); + + foreach (var marmalade in items) + { + eb.AddField(marmalade.Name, + $""" + `Canaries:` {marmalade.Canaries.Count} + `Commands:` {marmalade.Canaries.Sum(x => x.Commands.Count)} + -- + {marmalade.Description} + """); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeSearch() + { + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.list_of_marmalades)) + .WithOkColor(); + + foreach (var item in await _repo.GetModuleItemsAsync()) + { + eb.AddField(item.Name, + $""" + {item.Description} + `{item.Command}` + """, + true); + } + + await Response().Embed(eb).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Marmalade/MarmaladeItem.cs b/src/EllieBot/Modules/Marmalade/MarmaladeItem.cs new file mode 100644 index 0000000..54c2889 --- /dev/null +++ b/src/EllieBot/Modules/Marmalade/MarmaladeItem.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules; + +public sealed class ModuleItem +{ + public required string Name { get; init; } + public required string Description { get; init; } + public required string Command { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Marmalade/MarmaladesRepositoryService.cs b/src/EllieBot/Modules/Marmalade/MarmaladesRepositoryService.cs new file mode 100644 index 0000000..6cc12b5 --- /dev/null +++ b/src/EllieBot/Modules/Marmalade/MarmaladesRepositoryService.cs @@ -0,0 +1,67 @@ +namespace EllieBot.Modules; + +public class MarmaladesRepositoryService : IMarmaladesRepositoryService, IEService +{ + public async Task> GetModuleItemsAsync() + { + // Simulate retrieving data from a database or API + await Task.Delay(100); + return + [ + new() + { + Name = "RSS Reader", + Description = "Keep up to date with your favorite websites", + Command = ".mainstall rss" + }, + new() + { + Name = "Password Manager", + Description = "Safely store and manage all your passwords", + Command = ".mainstall passwordmanager" + }, + new() + { + Name = "Browser Extension", + Description = "Enhance your browsing experience with useful tools", + Command = ".mainstall browserextension" + }, + new() + { + Name = "Video Downloader", + Description = "Download videos from popular websites", + Command = ".mainstall videodownloader" + }, + new() + { + Name = "Virtual Private Network", + Description = "Securely browse the web and protect your privacy", + Command = ".mainstall vpn" + }, + new() + { + Name = "Ad Blocker", + Description = "Block annoying ads and improve page load times", + Command = ".mainstall adblocker" + }, + new() + { + Name = "Cloud Storage", + Description = "Store and share your files online", + Command = ".mainstall cloudstorage" + }, + new() + { + Name = "Social Media Manager", + Description = "Manage all your social media accounts in one place", + Command = ".mainstall socialmediamanager" + }, + new() + { + Name = "Code Editor", + Description = "Write and edit code online", + Command = ".mainstall codeeditor" + } + ]; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Music.cs b/src/EllieBot/Modules/Music/Music.cs new file mode 100644 index 0000000..3b1393c --- /dev/null +++ b/src/EllieBot/Modules/Music/Music.cs @@ -0,0 +1,755 @@ +#nullable disable +using EllieBot.Modules.Music.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Music; + +[NoPublicBot] +public sealed partial class Music : EllieModule +{ + public enum All { All = -1 } + + public enum InputRepeatType + { + N = 0, No = 0, None = 0, + T = 1, Track = 1, S = 1, Song = 1, + Q = 2, Queue = 2, Playlist = 2, Pl = 2 + } + + public const string MUSIC_ICON_URL = "https://i.imgur.com/nhKS3PT.png"; + + private const int LQ_ITEMS_PER_PAGE = 9; + + private static readonly SemaphoreSlim _voiceChannelLock = new(1, 1); + private readonly ILogCommandService _logService; + + public Music(ILogCommandService logService) + => _logService = logService; + + private async Task ValidateAsync() + { + var user = (IGuildUser)ctx.User; + var userVoiceChannelId = user.VoiceChannel?.Id; + + if (userVoiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return false; + } + + var currentUser = await ctx.Guild.GetCurrentUserAsync(); + if (currentUser.VoiceChannel?.Id != userVoiceChannelId) + { + await Response().Error(strs.not_with_bot_in_voice).SendAsync(); + return false; + } + + return true; + } + + private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null) + { + botUser ??= await ctx.Guild.GetCurrentUserAsync(); + await _voiceChannelLock.WaitAsync(); + try + { + if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _)) + await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId); + } + finally + { + _voiceChannelLock.Release(); + } + } + + private async Task QueuePreconditionInternalAsync() + { + var user = (IGuildUser)ctx.User; + var voiceChannelId = user.VoiceChannel?.Id; + + if (voiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return false; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var botUser = await ctx.Guild.GetCurrentUserAsync(); + await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser); + + if (botUser.VoiceChannel?.Id != voiceChannelId) + { + await Response().Error(strs.not_with_bot_in_voice).SendAsync(); + return false; + } + + return true; + } + + private async Task QueueByQuery(string query, bool asNext = false, MusicPlatform? forcePlatform = null) + { + var succ = await QueuePreconditionInternalAsync(); + if (!succ) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + var (trackInfo, index) = await mp.TryEnqueueTrackAsync(query, ctx.User.ToString(), asNext, forcePlatform); + if (trackInfo is null) + { + await Response().Error(strs.track_not_found).SendAsync(); + return; + } + + try + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(strs.queued_track) + " #" + (index + 1), MUSIC_ICON_URL) + .WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ") + .WithFooter(trackInfo.Platform.ToString()); + + if (!string.IsNullOrWhiteSpace(trackInfo.Thumbnail)) + embed.WithThumbnailUrl(trackInfo.Thumbnail); + + var queuedMessage = await _service.SendToOutputAsync(ctx.Guild.Id, embed); + queuedMessage?.DeleteAfter(10, _logService); + if (mp.IsStopped) + { + var msg = await Response().Pending(strs.queue_stopped(Format.Code(prefix + "play"))).SendAsync(); + msg.DeleteAfter(10, _logService); + } + } + catch + { + // ignored + } + } + + private async Task MoveToIndex(int index) + { + if (--index < 0) + return; + + var succ = await QueuePreconditionInternalAsync(); + if (!succ) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.MoveTo(index); + } + + // join vc + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Join() + { + var user = (IGuildUser)ctx.User; + + var voiceChannelId = user.VoiceChannel?.Id; + + if (voiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + await _service.JoinVoiceChannelAsync(user.GuildId, voiceChannelId.Value); + } + + // leave vc (destroy) + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Destroy() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + await _service.LeaveVoiceChannelAsync(ctx.Guild.Id); + } + + // play - no args = next + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(2)] + public Task Play() + => Next(); + + // play - index = skip to that index + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task Play(int index) + => MoveToIndex(index); + + // play - query = q(query) + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task Play([Leftover] string query) + => QueueByQuery(query); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Queue([Leftover] string query) + => QueueByQuery(query); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task QueueNext([Leftover] string query) + => QueueByQuery(query, true); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Volume(int vol) + { + if (vol is < 0 or > 100) + { + await Response().Error(strs.volume_input_invalid).SendAsync(); + return; + } + + var valid = await ValidateAsync(); + if (!valid) + return; + + await _service.SetVolumeAsync(ctx.Guild.Id, vol); + await Response().Confirm(strs.volume_set(vol)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Next() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + var success = await _service.PlayAsync(ctx.Guild.Id, ((IGuildUser)ctx.User).VoiceChannel.Id); + if (!success) + await Response().Error(strs.no_player).SendAsync(); + } + + // list queue, relevant page + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ListQueue() + { + // show page with the current track + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + await ListQueue((mp.CurrentIndex / LQ_ITEMS_PER_PAGE) + 1); + } + + // list queue, specify page + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ListQueue(int page) + { + if (--page < 0) + return; + + IReadOnlyCollection tracks; + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) || (tracks = mp.GetQueuedTracks()).Count == 0) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + EmbedBuilder PrintAction(IReadOnlyList tracks, int curPage) + { + var desc = string.Empty; + var current = mp.GetCurrentTrack(out var currentIndex); + if (current is not null) + desc = $"`🔊` {current.PrettyFullName()}\n\n" + desc; + + var repeatType = mp.Repeat; + var add = string.Empty; + if (mp.IsStopped) + add += Format.Bold(GetText(strs.queue_stopped(Format.Code(prefix + "play")))) + "\n"; + // var mps = mp.MaxPlaytimeSeconds; + // if (mps > 0) + // add += Format.Bold(GetText(strs.song_skips_after(TimeSpan.FromSeconds(mps).ToString("HH\\:mm\\:ss")))) + "\n"; + if (repeatType == PlayerRepeatType.Track) + add += "🔂 " + GetText(strs.repeating_track) + "\n"; + else + { + if (mp.AutoPlay) + add += "↪ " + GetText(strs.autoplaying) + "\n"; + // if (mp.FairPlay && !mp.Autoplay) + // add += " " + GetText(strs.fairplay) + "\n"; + if (repeatType == PlayerRepeatType.Queue) + add += "🔁 " + GetText(strs.repeating_queue) + "\n"; + } + + + desc += tracks + .Select((v, index) => + { + index += LQ_ITEMS_PER_PAGE * curPage; + if (index == currentIndex) + return $"**⇒**`{index + 1}.` {v.PrettyFullName()}"; + + return $"`{index + 1}.` {v.PrettyFullName()}"; + }) + .Join('\n'); + + if (!string.IsNullOrWhiteSpace(add)) + desc = add + "\n" + desc; + + var embed = _sender.CreateEmbed() + .WithAuthor( + GetText(strs.player_queue(curPage + 1, (tracks.Count / LQ_ITEMS_PER_PAGE) + 1)), + MUSIC_ICON_URL) + .WithDescription(desc) + .WithFooter( + $" {mp.PrettyVolume()} | 🎶 {tracks.Count} | ⌛ {mp.PrettyTotalTime()} ") + .WithOkColor(); + + return embed; + } + + await Response() + .Paginated() + .Items(tracks) + .PageSize(LQ_ITEMS_PER_PAGE) + .CurrentPage(page) + .AddFooter(false) + .Page(PrintAction) + .SendAsync(); + } + + // search + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QueueSearch([Leftover] string query) + { + _ = ctx.Channel.TriggerTypingAsync(); + + var videos = await _service.SearchVideosAsync(query); + + if (videos.Count == 0) + { + await Response().Error(strs.track_not_found).SendAsync(); + return; + } + + + var embeds = videos.Select((x, i) => _sender.CreateEmbed() + .WithOkColor() + .WithThumbnailUrl(x.Thumbnail) + .WithDescription($"`{i + 1}.` {Format.Bold(x.Title)}\n\t{x.Url}")) + .ToList(); + + var msg = await Response() + .Text(strs.queue_search_results) + .Embeds(embeds) + .SendAsync(); + + try + { + var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id, str => int.TryParse(str, out _)); + if (input is null || !int.TryParse(input, out var index) || (index -= 1) < 0 || index >= videos.Count) + { + _logService.AddDeleteIgnore(msg.Id); + try + { + await msg.DeleteAsync(); + } + catch + { + } + + return; + } + + query = videos[index].Url; + + await Play(query); + } + finally + { + _logService.AddDeleteIgnore(msg.Id); + try + { + await msg.DeleteAsync(); + } + catch + { + } + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task TrackRemove(int index) + { + if (index < 1) + { + await Response().Error(strs.removed_track_error).SendAsync(); + return; + } + + var valid = await ValidateAsync(); + if (!valid) + return; + + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + if (!mp.TryRemoveTrackAt(index - 1, out var track)) + { + await Response().Error(strs.removed_track_error).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithAuthor(GetText(strs.removed_track) + " #" + index, MUSIC_ICON_URL) + .WithDescription(track.PrettyName()) + .WithFooter(track.PrettyInfo()) + .WithErrorColor(); + + await _service.SendToOutputAsync(ctx.Guild.Id, embed); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task TrackRemove(All _ = All.All) + { + var valid = await ValidateAsync(); + if (!valid) + return; + + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.Clear(); + await Response().Confirm(strs.queue_cleared).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Stop() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.Stop(); + } + + private PlayerRepeatType InputToDbType(InputRepeatType type) + => type switch + { + InputRepeatType.None => PlayerRepeatType.None, + InputRepeatType.Queue => PlayerRepeatType.Queue, + InputRepeatType.Track => PlayerRepeatType.Track, + _ => PlayerRepeatType.Queue + }; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QueueRepeat(InputRepeatType type = InputRepeatType.Queue) + { + var valid = await ValidateAsync(); + if (!valid) + return; + + await _service.SetRepeatAsync(ctx.Guild.Id, InputToDbType(type)); + + if (type == InputRepeatType.None) + await Response().Confirm(strs.repeating_none).SendAsync(); + else if (type == InputRepeatType.Queue) + await Response().Confirm(strs.repeating_queue).SendAsync(); + else + await Response().Confirm(strs.repeating_track).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Pause() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) || mp.GetCurrentTrack(out _) is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.TogglePause(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Radio(string radioLink) + => QueueByQuery(radioLink, false, MusicPlatform.Radio); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public Task Local([Leftover] string path) + => QueueByQuery(path, false, MusicPlatform.Local); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task LocalPlaylist([Leftover] string dirPath) + { + if (string.IsNullOrWhiteSpace(dirPath)) + return; + + var user = (IGuildUser)ctx.User; + var voiceChannelId = user.VoiceChannel?.Id; + + if (voiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var botUser = await ctx.Guild.GetCurrentUserAsync(); + await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser); + + if (botUser.VoiceChannel?.Id != voiceChannelId) + { + await Response().Error(strs.not_with_bot_in_voice).SendAsync(); + return; + } + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + await _service.EnqueueDirectoryAsync(mp, dirPath, ctx.User.ToString()); + + await Response().Confirm(strs.dir_queue_complete).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task TrackMove(int from, int to) + { + if (--from < 0 || --to < 0 || from == to) + { + await Response().Error(strs.invalid_input).SendAsync(); + return; + } + + var valid = await ValidateAsync(); + if (!valid) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + var track = mp.MoveTrack(from, to); + if (track is null) + { + await Response().Error(strs.invalid_input).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithTitle(track.Title.TrimTo(65)) + .WithAuthor(GetText(strs.track_moved), MUSIC_ICON_URL) + .AddField(GetText(strs.from_position), $"#{from + 1}", true) + .AddField(GetText(strs.to_position), $"#{to + 1}", true) + .WithOkColor(); + + if (Uri.IsWellFormedUriString(track.Url, UriKind.Absolute)) + embed.WithUrl(track.Url); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Playlist([Leftover] string playlistQuery) + { + if (string.IsNullOrWhiteSpace(playlistQuery)) + return; + + var succ = await QueuePreconditionInternalAsync(); + if (!succ) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + + var queuedCount = await _service.EnqueueYoutubePlaylistAsync(mp, playlistQuery, ctx.User.ToString()); + if (queuedCount == 0) + { + await Response().Error(strs.no_search_results).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task NowPlaying() + { + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + var currentTrack = mp.GetCurrentTrack(out _); + if (currentTrack is null) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(strs.now_playing), MUSIC_ICON_URL) + .WithDescription(currentTrack.PrettyName()) + .WithThumbnailUrl(currentTrack.Thumbnail) + .WithFooter( + $"{mp.PrettyVolume()} | {mp.PrettyTotalTime()} | {currentTrack.Platform} | {currentTrack.Queuer}"); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task PlaylistShuffle() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.ShuffleQueue(); + await Response().Confirm(strs.queue_shuffled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task SetMusicChannel() + { + await _service.SetMusicChannelAsync(ctx.Guild.Id, ctx.Channel.Id); + + await Response().Confirm(strs.set_music_channel).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task UnsetMusicChannel() + { + await _service.SetMusicChannelAsync(ctx.Guild.Id, null); + + await Response().Confirm(strs.unset_music_channel).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AutoDisconnect() + { + var newState = await _service.ToggleAutoDisconnectAsync(ctx.Guild.Id); + + if (newState) + await Response().Confirm(strs.autodc_enable).SendAsync(); + else + await Response().Confirm(strs.autodc_disable).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task MusicQuality() + { + var quality = await _service.GetMusicQualityAsync(ctx.Guild.Id); + await Response().Confirm(strs.current_music_quality(Format.Bold(quality.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task MusicQuality(QualityPreset preset) + { + await _service.SetMusicQualityAsync(ctx.Guild.Id, preset); + await Response().Confirm(strs.music_quality_set(Format.Bold(preset.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QueueAutoPlay() + { + var newValue = await _service.ToggleQueueAutoPlayAsync(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.music_autoplay_on).SendAsync(); + else + await Response().Confirm(strs.music_autoplay_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QueueFairplay() + { + var newValue = await _service.FairplayAsync(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.music_fairplay).SendAsync(); + else + await Response().Error(strs.no_player).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/PlaylistCommands.cs b/src/EllieBot/Modules/Music/PlaylistCommands.cs new file mode 100644 index 0000000..259bb9e --- /dev/null +++ b/src/EllieBot/Modules/Music/PlaylistCommands.cs @@ -0,0 +1,246 @@ +#nullable disable +using LinqToDB; +using EllieBot.Db; +using EllieBot.Modules.Music.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Music; + +public sealed partial class Music +{ + [Group] + public sealed partial class PlaylistCommands : EllieModule + { + private static readonly SemaphoreSlim _playlistLock = new(1, 1); + private readonly DbService _db; + private readonly IBotCredentials _creds; + + public PlaylistCommands(DbService db, IBotCredentials creds) + { + _db = db; + _creds = creds; + } + + private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null) + { + botUser ??= await ctx.Guild.GetCurrentUserAsync(); + await _voiceChannelLock.WaitAsync(); + try + { + if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _)) + await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId); + } + finally + { + _voiceChannelLock.Release(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Playlists([Leftover] int num = 1) + { + if (num <= 0) + return; + + List playlists; + + await using (var uow = _db.GetDbContext()) + { + playlists = uow.Set().GetPlaylistsOnPage(num); + } + + var embed = _sender.CreateEmbed() + .WithAuthor(GetText(strs.playlists_page(num)), MUSIC_ICON_URL) + .WithDescription(string.Join("\n", + playlists.Select(r => GetText(strs.playlists(r.Id, r.Name, r.Author, r.Songs.Count))))) + .WithOkColor(); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task DeletePlaylist([Leftover] int id) + { + var success = false; + try + { + await using var uow = _db.GetDbContext(); + var pl = uow.Set().FirstOrDefault(x => x.Id == id); + + if (pl is not null) + { + if (_creds.IsOwner(ctx.User) || pl.AuthorId == ctx.User.Id) + { + uow.Set().Remove(pl); + await uow.SaveChangesAsync(); + success = true; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error deleting playlist"); + } + + if (!success) + await Response().Error(strs.playlist_delete_fail).SendAsync(); + else + await Response().Confirm(strs.playlist_deleted).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task PlaylistShow(int id, int page = 1) + { + if (page-- < 1) + return; + + MusicPlaylist mpl; + await using (var uow = _db.GetDbContext()) + { + mpl = uow.Set().GetWithSongs(id); + } + + await Response() + .Paginated() + .Items(mpl.Songs) + .PageSize(20) + .CurrentPage(page) + .Page((items, _) => + { + var i = 0; + var str = string.Join("\n", + items + .Select(x => $"`{++i}.` [{x.Title.TrimTo(45)}]({x.Query}) `{x.Provider}`")); + return _sender.CreateEmbed().WithTitle($"\"{mpl.Name}\" by {mpl.Author}") + .WithOkColor() + .WithDescription(str); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Save([Leftover] string name) + { + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + var songs = mp.GetQueuedTracks() + .Select(s => new PlaylistSong + { + Provider = s.Platform.ToString(), + ProviderType = (MusicType)s.Platform, + Title = s.Title, + Query = s.Platform == MusicPlatform.Local ? s.GetStreamUrl().Result!.Trim('"') : s.Url + }) + .ToList(); + + MusicPlaylist playlist; + await using (var uow = _db.GetDbContext()) + { + playlist = new() + { + Name = name, + Author = ctx.User.Username, + AuthorId = ctx.User.Id, + Songs = songs.ToList() + }; + uow.Set().Add(playlist); + await uow.SaveChangesAsync(); + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.playlist_saved)) + .AddField(GetText(strs.name), name) + .AddField(GetText(strs.id), playlist.Id.ToString())) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Load([Leftover] int id) + { + // expensive action, 1 at a time + await _playlistLock.WaitAsync(); + try + { + var user = (IGuildUser)ctx.User; + var voiceChannelId = user.VoiceChannel?.Id; + + if (voiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var botUser = await ctx.Guild.GetCurrentUserAsync(); + await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser); + + if (botUser.VoiceChannel?.Id != voiceChannelId) + { + await Response().Error(strs.not_with_bot_in_voice).SendAsync(); + return; + } + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + MusicPlaylist mpl; + await using (var uow = _db.GetDbContext()) + { + mpl = uow.Set().GetWithSongs(id); + } + + if (mpl is null) + { + await Response().Error(strs.playlist_id_not_found).SendAsync(); + return; + } + + IUserMessage msg = null; + try + { + msg = await Response() + .Pending(strs.attempting_to_queue(Format.Bold(mpl.Songs.Count.ToString()))) + .SendAsync(); + } + catch (Exception) + { + } + + await mp.EnqueueManyAsync(mpl.Songs.Select(x => (x.Query, (MusicPlatform)x.ProviderType)), + ctx.User.ToString()); + + if (msg is not null) + await msg.ModifyAsync(m => m.Content = GetText(strs.playlist_queue_complete)); + } + finally + { + _playlistLock.Release(); + } + } + + [Cmd] + [OwnerOnly] + public async Task DeletePlaylists() + { + await using var uow = _db.GetDbContext(); + await uow.Set().DeleteAsync(); + await uow.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs b/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs new file mode 100644 index 0000000..ff47354 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs @@ -0,0 +1,217 @@ +#nullable disable +using EllieBot.Voice; +using System.Reflection; + +namespace EllieBot.Modules.Music.Services; + +public sealed class AyuVoiceStateService : IEService +{ + // public delegate Task VoiceProxyUpdatedDelegate(ulong guildId, IVoiceProxy proxy); + // public event VoiceProxyUpdatedDelegate OnVoiceProxyUpdate = delegate { return Task.CompletedTask; }; + + private readonly ConcurrentDictionary _voiceProxies = new(); + private readonly ConcurrentDictionary _voiceGatewayLocks = new(); + + private readonly DiscordSocketClient _client; + private readonly MethodInfo _sendVoiceStateUpdateMethodInfo; + private readonly object _dnetApiClient; + private readonly ulong _currentUserId; + + public AyuVoiceStateService(DiscordSocketClient client) + { + _client = client; + _currentUserId = _client.CurrentUser.Id; + + var prop = _client.GetType() + .GetProperties(BindingFlags.NonPublic | BindingFlags.Instance) + .First(x => x.Name == "ApiClient" && x.PropertyType.Name == "DiscordSocketApiClient"); + _dnetApiClient = prop.GetValue(_client, null); + _sendVoiceStateUpdateMethodInfo = _dnetApiClient.GetType() + .GetMethod("SendVoiceStateUpdateAsync", + [ + typeof(ulong), typeof(ulong?), typeof(bool), + typeof(bool), typeof(RequestOptions) + ]); + + _client.LeftGuild += ClientOnLeftGuild; + } + + private Task ClientOnLeftGuild(SocketGuild guild) + { + if (_voiceProxies.TryRemove(guild.Id, out var proxy)) + { + proxy.StopGateway(); + proxy.SetGateway(null); + } + + return Task.CompletedTask; + } + + private Task InvokeSendVoiceStateUpdateAsync( + ulong guildId, + ulong? channelId = null, + bool isDeafened = false, + bool isMuted = false) + // return _voiceStateUpdate(guildId, channelId, isDeafened, isMuted); + => (Task)_sendVoiceStateUpdateMethodInfo.Invoke(_dnetApiClient, + [guildId, channelId, isMuted, isDeafened, null]); + + private Task SendLeaveVoiceChannelInternalAsync(ulong guildId) + => InvokeSendVoiceStateUpdateAsync(guildId); + + private Task SendJoinVoiceChannelInternalAsync(ulong guildId, ulong channelId) + => InvokeSendVoiceStateUpdateAsync(guildId, channelId); + + private SemaphoreSlim GetVoiceGatewayLock(ulong guildId) + => _voiceGatewayLocks.GetOrAdd(guildId, new SemaphoreSlim(1, 1)); + + private async Task LeaveVoiceChannelInternalAsync(ulong guildId) + { + var complete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState) + { + if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId && newState.VoiceChannel?.Id is null) + complete.TrySetResult(true); + + return Task.CompletedTask; + } + + try + { + _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated; + + if (_voiceProxies.TryGetValue(guildId, out var proxy)) + { + _ = proxy.StopGateway(); + proxy.SetGateway(null); + } + + await SendLeaveVoiceChannelInternalAsync(guildId); + await Task.WhenAny(Task.Delay(1500), complete.Task); + } + finally + { + _client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated; + } + } + + public async Task LeaveVoiceChannel(ulong guildId) + { + var gwLock = GetVoiceGatewayLock(guildId); + await gwLock.WaitAsync(); + try + { + await LeaveVoiceChannelInternalAsync(guildId); + } + finally + { + gwLock.Release(); + } + } + + private async Task InternalConnectToVcAsync(ulong guildId, ulong channelId) + { + var voiceStateUpdatedSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var voiceServerUpdatedSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState) + { + if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId) + { + if (newState.VoiceChannel?.Id == channelId) + voiceStateUpdatedSource.TrySetResult(newState.VoiceSessionId); + + voiceStateUpdatedSource.TrySetResult(null); + } + + return Task.CompletedTask; + } + + Task OnVoiceServerUpdated(SocketVoiceServer data) + { + if (data.Guild.Id == guildId) + voiceServerUpdatedSource.TrySetResult(data); + + return Task.CompletedTask; + } + + try + { + _client.VoiceServerUpdated += OnVoiceServerUpdated; + _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated; + + await SendJoinVoiceChannelInternalAsync(guildId, channelId); + + // create a delay task, how much to wait for gateway response + using var cts = new CancellationTokenSource(); + var delayTask = Task.Delay(2500, cts.Token); + + // either delay or successful voiceStateUpdate + var maybeUpdateTask = Task.WhenAny(delayTask, voiceStateUpdatedSource.Task); + // either delay or successful voiceServerUpdate + var maybeServerTask = Task.WhenAny(delayTask, voiceServerUpdatedSource.Task); + + // wait for both to end (max 1s) and check if either of them is a delay task + var results = await Task.WhenAll(maybeUpdateTask, maybeServerTask); + if (results[0] == delayTask || results[1] == delayTask) + // if either is delay, return null - connection unsuccessful + return null; + else + cts.Cancel(); + + // if both are succesful, that means we can safely get + // the values from completion sources + + var session = await voiceStateUpdatedSource.Task; + + // session can be null. Means we disconnected, or connected to the wrong channel (?!) + if (session is null) + return null; + + var voiceServerData = await voiceServerUpdatedSource.Task; + + VoiceGateway CreateVoiceGatewayLocal() + { + return new(guildId, _currentUserId, session, voiceServerData.Token, voiceServerData.Endpoint); + } + + var current = _voiceProxies.AddOrUpdate(guildId, + _ => new VoiceProxy(CreateVoiceGatewayLocal()), + (gid, currentProxy) => + { + _ = currentProxy.StopGateway(); + currentProxy.SetGateway(CreateVoiceGatewayLocal()); + return currentProxy; + }); + + _ = current.StartGateway(); // don't await, this blocks until gateway is closed + return current; + } + finally + { + _client.VoiceServerUpdated -= OnVoiceServerUpdated; + _client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated; + } + } + + public async Task JoinVoiceChannel(ulong guildId, ulong channelId, bool forceReconnect = true) + { + var gwLock = GetVoiceGatewayLock(guildId); + await gwLock.WaitAsync(); + try + { + await LeaveVoiceChannelInternalAsync(guildId); + return await InternalConnectToVcAsync(guildId, channelId); + } + finally + { + gwLock.Release(); + } + } + + public bool TryGetProxy(ulong guildId, out IVoiceProxy proxy) + => _voiceProxies.TryGetValue(guildId, out proxy); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/IMusicService.cs b/src/EllieBot/Modules/Music/Services/IMusicService.cs new file mode 100644 index 0000000..43bad99 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/IMusicService.cs @@ -0,0 +1,36 @@ +using EllieBot.Db.Models; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Music.Services; + +public interface IMusicService +{ + /// + /// Leave voice channel in the specified guild if it's connected to one + /// + /// Id of the guild + public Task LeaveVoiceChannelAsync(ulong guildId); + + /// + /// Joins the voice channel with the specified id + /// + /// Id of the guild where the voice channel is + /// Id of the voice channel + public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId); + + Task GetOrCreateMusicPlayerAsync(ITextChannel contextChannel); + bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer); + Task EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string playlistId, string queuer); + Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer); + Task SendToOutputAsync(ulong guildId, EmbedBuilder embed); + Task PlayAsync(ulong guildId, ulong voiceChannelId); + Task> SearchVideosAsync(string query); + Task SetMusicChannelAsync(ulong guildId, ulong? channelId); + Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType); + Task SetVolumeAsync(ulong guildId, int value); + Task ToggleAutoDisconnectAsync(ulong guildId); + Task GetMusicQualityAsync(ulong guildId); + Task SetMusicQualityAsync(ulong guildId, QualityPreset preset); + Task ToggleQueueAutoPlayAsync(ulong guildId); + Task FairplayAsync(ulong guildId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/MusicService.cs b/src/EllieBot/Modules/Music/Services/MusicService.cs new file mode 100644 index 0000000..b2b3da3 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/MusicService.cs @@ -0,0 +1,437 @@ +using EllieBot.Db; +using EllieBot.Db.Models; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Music.Services; + +public sealed class MusicService : IMusicService, IPlaceholderProvider +{ + private readonly AyuVoiceStateService _voiceStateService; + private readonly ITrackResolveProvider _trackResolveProvider; + private readonly DbService _db; + private readonly IYoutubeResolver _ytResolver; + private readonly ILocalTrackResolver _localResolver; + private readonly DiscordSocketClient _client; + private readonly IBotStrings _strings; + private readonly IGoogleApiService _googleApiService; + private readonly YtLoader _ytLoader; + private readonly IMessageSenderService _sender; + + private readonly ConcurrentDictionary _players; + private readonly ConcurrentDictionary _outputChannels; + private readonly ConcurrentDictionary _settings; + + public MusicService( + AyuVoiceStateService voiceStateService, + ITrackResolveProvider trackResolveProvider, + DbService db, + IYoutubeResolver ytResolver, + ILocalTrackResolver localResolver, + DiscordSocketClient client, + IBotStrings strings, + IGoogleApiService googleApiService, + YtLoader ytLoader, + IMessageSenderService sender) + { + _voiceStateService = voiceStateService; + _trackResolveProvider = trackResolveProvider; + _db = db; + _ytResolver = ytResolver; + _localResolver = localResolver; + _client = client; + _strings = strings; + _googleApiService = googleApiService; + _ytLoader = ytLoader; + _sender = sender; + + _players = new(); + _outputChannels = new ConcurrentDictionary(); + _settings = new(); + + _client.LeftGuild += ClientOnLeftGuild; + } + + private void DisposeMusicPlayer(IMusicPlayer musicPlayer) + { + musicPlayer.Kill(); + _ = Task.Delay(10_000).ContinueWith(_ => musicPlayer.Dispose()); + } + + private void RemoveMusicPlayer(ulong guildId) + { + _outputChannels.TryRemove(guildId, out _); + if (_players.TryRemove(guildId, out var mp)) + DisposeMusicPlayer(mp); + } + + private Task ClientOnLeftGuild(SocketGuild guild) + { + RemoveMusicPlayer(guild.Id); + return Task.CompletedTask; + } + + public async Task LeaveVoiceChannelAsync(ulong guildId) + { + RemoveMusicPlayer(guildId); + await _voiceStateService.LeaveVoiceChannel(guildId); + } + + public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId) + => _voiceStateService.JoinVoiceChannel(guildId, voiceChannelId); + + public async Task GetOrCreateMusicPlayerAsync(ITextChannel contextChannel) + { + var newPLayer = await CreateMusicPlayerInternalAsync(contextChannel.GuildId, contextChannel); + if (newPLayer is null) + return null; + + return _players.GetOrAdd(contextChannel.GuildId, newPLayer); + } + + public bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer) + => _players.TryGetValue(guildId, out musicPlayer); + + public async Task EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string query, string queuer) + { + var count = 0; + await foreach (var track in _ytResolver.ResolveTracksFromPlaylistAsync(query)) + { + if (mp.IsKilled) + break; + + mp.EnqueueTrack(track, queuer); + ++count; + } + + return count; + } + + public async Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer) + { + await foreach (var track in _localResolver.ResolveDirectoryAsync(dirPath)) + { + if (mp.IsKilled) + break; + + mp.EnqueueTrack(track, queuer); + } + } + + private async Task CreateMusicPlayerInternalAsync(ulong guildId, ITextChannel defaultChannel) + { + var queue = new MusicQueue(); + var resolver = _trackResolveProvider; + + if (!_voiceStateService.TryGetProxy(guildId, out var proxy)) + return null; + + var settings = await GetSettingsInternalAsync(guildId); + + ITextChannel? overrideChannel = null; + if (settings.MusicChannelId is { } channelId) + { + overrideChannel = _client.GetGuild(guildId)?.GetTextChannel(channelId); + + if (overrideChannel is null) + Log.Warning("Saved music output channel doesn't exist, falling back to current channel"); + } + + _outputChannels[guildId] = (defaultChannel, overrideChannel); + + var mp = new MusicPlayer(queue, + resolver, + proxy, + _googleApiService, + settings.QualityPreset, + settings.AutoPlay); + + mp.SetRepeat(settings.PlayerRepeat); + + if (settings.Volume is >= 0 and <= 100) + mp.SetVolume(settings.Volume); + else + Log.Error("Saved Volume is outside of valid range >= 0 && <=100 ({Volume})", settings.Volume); + + mp.OnCompleted += OnTrackCompleted(guildId); + mp.OnStarted += OnTrackStarted(guildId); + mp.OnQueueStopped += OnQueueStopped(guildId); + + return mp; + } + + public async Task SendToOutputAsync(ulong guildId, EmbedBuilder embed) + { + if (_outputChannels.TryGetValue(guildId, out var chan)) + { + var msg = await _sender.Response(chan.Override ?? chan.Default) + .Embed(embed) + .SendAsync(); + return msg; + } + + return null; + } + + private Func OnTrackCompleted(ulong guildId) + { + IUserMessage? lastFinishedMessage = null; + return async (mp, trackInfo) => + { + _ = lastFinishedMessage?.DeleteAsync(); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(guildId, strs.finished_track), Music.MUSIC_ICON_URL) + .WithDescription(trackInfo.PrettyName()) + .WithFooter(trackInfo.PrettyTotalTime()); + + lastFinishedMessage = await SendToOutputAsync(guildId, embed); + }; + } + + private Func OnTrackStarted(ulong guildId) + { + IUserMessage? lastPlayingMessage = null; + return async (mp, trackInfo, index) => + { + _ = lastPlayingMessage?.DeleteAsync(); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(guildId, strs.playing_track(index + 1)), Music.MUSIC_ICON_URL) + .WithDescription(trackInfo.PrettyName()) + .WithFooter($"{mp.PrettyVolume()} | {trackInfo.PrettyInfo()}"); + + lastPlayingMessage = await SendToOutputAsync(guildId, embed); + }; + } + + private Func OnQueueStopped(ulong guildId) + => _ => + { + if (_settings.TryGetValue(guildId, out var settings)) + { + if (settings.AutoDisconnect) + return LeaveVoiceChannelAsync(guildId); + } + + return Task.CompletedTask; + }; + + // this has to be done because dragging bot to another vc isn't supported yet + public async Task PlayAsync(ulong guildId, ulong voiceChannelId) + { + if (!TryGetMusicPlayer(guildId, out var mp)) + return false; + + if (mp.IsStopped) + { + if (!_voiceStateService.TryGetProxy(guildId, out var proxy) + || proxy.State == VoiceProxy.VoiceProxyState.Stopped) + await JoinVoiceChannelAsync(guildId, voiceChannelId); + } + + mp.Next(); + return true; + } + + private async Task> SearchYtLoaderVideosAsync(string query) + { + var result = await _ytLoader.LoadResultsAsync(query); + return result.Select(x => (x.Title, x.Url, x.Thumb)).ToList(); + } + + private async Task> SearchGoogleApiVideosAsync(string query) + { + var result = await _googleApiService.GetVideoInfosByKeywordAsync(query, 5); + return result.Select(x => (x.Name, x.Url, x.Thumbnail)).ToList(); + } + + public async Task> SearchVideosAsync(string query) + { + try + { + IList<(string, string, string)> videos = await SearchYtLoaderVideosAsync(query); + if (videos.Count > 0) + return videos; + } + catch (Exception ex) + { + Log.Warning("Failed geting videos with YtLoader: {ErrorMessage}", ex.Message); + } + + try + { + return await SearchGoogleApiVideosAsync(query); + } + catch (Exception ex) + { + Log.Warning("Failed getting video results with Google Api. " + + "Probably google api key missing: {ErrorMessage}", + ex.Message); + } + + return Array.Empty<(string, string, string)>(); + } + + private string GetText(ulong guildId, LocStr str) + => _strings.GetText(str, guildId); + + public IEnumerable<(string Name, Func Func)> GetPlaceholders() + { + // random track that's playing + yield return ("%music.playing%", () => + { + var randomPlayingTrack = _players.Select(x => x.Value.GetCurrentTrack(out _)) + .Where(x => x is not null) + .Shuffle() + .FirstOrDefault(); + + if (randomPlayingTrack is null) + return "-"; + + return randomPlayingTrack.Title; + }); + + // number of servers currently listening to music + yield return ("%music.servers%", () => + { + var count = _players.Select(x => x.Value.GetCurrentTrack(out _)).Count(x => x is not null); + + return count.ToString(); + }); + + yield return ("%music.queued%", () => + { + var count = _players.Sum(x => x.Value.GetQueuedTracks().Count); + + return count.ToString(); + }); + } + + #region Settings + + private async Task GetSettingsInternalAsync(ulong guildId) + { + if (_settings.TryGetValue(guildId, out var settings)) + return settings; + + await using var uow = _db.GetDbContext(); + var toReturn = _settings[guildId] = await uow.Set().ForGuildAsync(guildId); + await uow.SaveChangesAsync(); + + return toReturn; + } + + private async Task ModifySettingsInternalAsync( + ulong guildId, + Action action, + TState state) + { + await using var uow = _db.GetDbContext(); + var ms = await uow.Set().ForGuildAsync(guildId); + action(ms, state); + await uow.SaveChangesAsync(); + _settings[guildId] = ms; + } + + public async Task SetMusicChannelAsync(ulong guildId, ulong? channelId) + { + if (channelId is null) + { + await UnsetMusicChannelAsync(guildId); + return true; + } + + var channel = _client.GetGuild(guildId)?.GetTextChannel(channelId.Value); + if (channel is null) + return false; + + await ModifySettingsInternalAsync(guildId, + (settings, chId) => { settings.MusicChannelId = chId; }, + channelId); + + _outputChannels.AddOrUpdate(guildId, (channel, channel), (_, old) => (old.Default, channel)); + + return true; + } + + public async Task UnsetMusicChannelAsync(ulong guildId) + { + await ModifySettingsInternalAsync(guildId, + (settings, _) => { settings.MusicChannelId = null; }, + (ulong?)null); + + if (_outputChannels.TryGetValue(guildId, out var old)) + _outputChannels[guildId] = (old.Default, null); + } + + public async Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType) + { + await ModifySettingsInternalAsync(guildId, + (settings, type) => { settings.PlayerRepeat = type; }, + repeatType); + + if (TryGetMusicPlayer(guildId, out var mp)) + mp.SetRepeat(repeatType); + } + + public async Task SetVolumeAsync(ulong guildId, int value) + { + if (value is < 0 or > 100) + throw new ArgumentOutOfRangeException(nameof(value)); + + await ModifySettingsInternalAsync(guildId, + (settings, newValue) => { settings.Volume = newValue; }, + value); + + if (TryGetMusicPlayer(guildId, out var mp)) + mp.SetVolume(value); + } + + public async Task ToggleAutoDisconnectAsync(ulong guildId) + { + var newState = false; + await ModifySettingsInternalAsync(guildId, + (settings, _) => { newState = settings.AutoDisconnect = !settings.AutoDisconnect; }, + default(object)); + + return newState; + } + + public async Task GetMusicQualityAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var settings = await uow.Set().ForGuildAsync(guildId); + return settings.QualityPreset; + } + + public Task SetMusicQualityAsync(ulong guildId, QualityPreset preset) + => ModifySettingsInternalAsync(guildId, + (settings, _) => { settings.QualityPreset = preset; }, + preset); + + public async Task ToggleQueueAutoPlayAsync(ulong guildId) + { + var newValue = false; + await ModifySettingsInternalAsync(guildId, + (settings, _) => newValue = settings.AutoPlay = !settings.AutoPlay, + false); + + if (TryGetMusicPlayer(guildId, out var mp)) + mp.AutoPlay = newValue; + + return newValue; + } + + public Task FairplayAsync(ulong guildId) + { + if (TryGetMusicPlayer(guildId, out var mp)) + { + mp.SetFairplay(); + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + #endregion +} diff --git a/src/EllieBot/Modules/Music/Services/extractor/Misc.cs b/src/EllieBot/Modules/Music/Services/extractor/Misc.cs new file mode 100644 index 0000000..68c1fca --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/extractor/Misc.cs @@ -0,0 +1,74 @@ +#nullable disable +namespace EllieBot.Modules.Music.Services; + +public sealed partial class YtLoader +{ + public class InitRange + { + public string Start { get; set; } + public string End { get; set; } + } + + public class IndexRange + { + public string Start { get; set; } + public string End { get; set; } + } + + public class ColorInfo + { + public string Primaries { get; set; } + public string TransferCharacteristics { get; set; } + public string MatrixCoefficients { get; set; } + } + + public class YtAdaptiveFormat + { + public int Itag { get; set; } + public string MimeType { get; set; } + public int Bitrate { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public InitRange InitRange { get; set; } + public IndexRange IndexRange { get; set; } + public string LastModified { get; set; } + public string ContentLength { get; set; } + public string Quality { get; set; } + public int Fps { get; set; } + public string QualityLabel { get; set; } + public string ProjectionType { get; set; } + public int AverageBitrate { get; set; } + public ColorInfo ColorInfo { get; set; } + public string ApproxDurationMs { get; set; } + public string SignatureCipher { get; set; } + } + + public abstract class TrackInfo + { + public abstract string Url { get; } + public abstract string Title { get; } + public abstract string Thumb { get; } + public abstract TimeSpan Duration { get; } + } + + public sealed class YtTrackInfo : TrackInfo + { + private const string BASE_YOUTUBE_URL = "https://youtube.com/watch?v="; + public override string Url { get; } + public override string Title { get; } + public override string Thumb { get; } + public override TimeSpan Duration { get; } + + private readonly string _videoId; + + public YtTrackInfo(string title, string videoId, string thumb, TimeSpan duration) + { + Title = title; + Thumb = thumb; + Url = BASE_YOUTUBE_URL + videoId; + Duration = duration; + + _videoId = videoId; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs b/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs new file mode 100644 index 0000000..a91dd11 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs @@ -0,0 +1,130 @@ +#nullable disable +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace EllieBot.Modules.Music.Services; + +public sealed partial class YtLoader : IEService +{ + private static readonly byte[] _ytResultInitialData = Encoding.UTF8.GetBytes("var ytInitialData = "); + private static readonly byte[] _ytResultJsonEnd = Encoding.UTF8.GetBytes(";<"); + + private static readonly string[] _durationFormats = + [ + @"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss", @"hhh\:mm\:ss" + ]; + + private readonly IHttpClientFactory _httpFactory; + + public YtLoader(IHttpClientFactory httpFactory) + => _httpFactory = httpFactory; + + // public async Task LoadTrackByIdAsync(string videoId) + // { + // using var http = new HttpClient(); + // http.DefaultRequestHeaders.Add("X-YouTube-Client-Name", "1"); + // http.DefaultRequestHeaders.Add("X-YouTube-Client-Version", "2.20210520.09.00"); + // http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;"); + // + // var responseString = await http.GetStringAsync($"https://youtube.com?" + + // $"pbj=1" + + // $"&hl=en" + + // $"&v=" + videoId); + // + // var jsonDoc = JsonDocument.Parse(responseString).RootElement; + // var elem = jsonDoc.EnumerateArray() + // .FirstOrDefault(x => x.TryGetProperty("page", out var elem) && elem.GetString() == "watch"); + // + // var formatsJsonArray = elem.GetProperty("streamingdata") + // .GetProperty("formats") + // .GetRawText(); + // + // var formats = JsonSerializer.Deserialize>(formatsJsonArray); + // var result = formats + // .Where(x => x.MimeType.StartsWith("audio/")) + // .OrderByDescending(x => x.Bitrate) + // .FirstOrDefault(); + // + // if (result is null) + // return null; + // + // return new YtTrackInfo("1", "2", TimeSpan.Zero); + // } + + public async Task> LoadResultsAsync(string query) + { + query = Uri.EscapeDataString(query); + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;"); + + byte[] response; + try + { + response = await http.GetByteArrayAsync($"https://youtube.com/results?hl=en&search_query={query}"); + } + catch (HttpRequestException ex) + { + Log.Warning("Unable to retrieve data with YtLoader: {ErrorMessage}", ex.Message); + return null; + } + + // there is a lot of useless html above the script tag, however if html gets significantly reduced + // this will result in the json being cut off + + var mem = GetScriptResponseSpan(response); + var root = JsonDocument.Parse(mem).RootElement; + + using var tracksJsonItems = root + .GetProperty("contents") + .GetProperty("twoColumnSearchResultsRenderer") + .GetProperty("primaryContents") + .GetProperty("sectionListRenderer") + .GetProperty("contents")[0] + .GetProperty("itemSectionRenderer") + .GetProperty("contents") + .EnumerateArray(); + + var tracks = new List(); + foreach (var track in tracksJsonItems) + { + if (!track.TryGetProperty("videoRenderer", out var elem)) + continue; + + var videoId = elem.GetProperty("videoId").GetString(); + var thumb = elem.GetProperty("thumbnail").GetProperty("thumbnails")[0].GetProperty("url").GetString(); + var title = elem.GetProperty("title").GetProperty("runs")[0].GetProperty("text").GetString(); + var durationString = elem.GetProperty("lengthText").GetProperty("simpleText").GetString(); + + if (!TimeSpan.TryParseExact(durationString, + _durationFormats, + CultureInfo.InvariantCulture, + out var duration)) + { + Log.Warning("Cannot parse duration: {DurationString}", durationString); + continue; + } + + tracks.Add(new YtTrackInfo(title, videoId, thumb, duration)); + if (tracks.Count >= 5) + break; + } + + return tracks; + } + + private Memory GetScriptResponseSpan(byte[] response) + { + var responseSpan = response.AsSpan()[140_000..]; + var startIndex = responseSpan.IndexOf(_ytResultInitialData); + if (startIndex == -1) + return null; // FUTURE try selecting html + startIndex += _ytResultInitialData.Length; + + var endIndex = + 140_000 + startIndex + responseSpan[(startIndex + 20_000)..].IndexOf(_ytResultJsonEnd) + 20_000; + startIndex += 140_000; + return response.AsMemory(startIndex, endIndex - startIndex); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs b/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs new file mode 100644 index 0000000..020e074 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface ICachableTrackData +{ + string Id { get; set; } + string Url { get; set; } + string Thumbnail { get; set; } + public TimeSpan Duration { get; } + MusicPlatform Platform { get; set; } + string Title { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs b/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs new file mode 100644 index 0000000..f4ea2bf --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface ILocalTrackResolver : IPlatformQueryResolver +{ + IAsyncEnumerable ResolveDirectoryAsync(string dirPath); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs b/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs new file mode 100644 index 0000000..a593a57 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs @@ -0,0 +1,41 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Music; + +public interface IMusicPlayer : IDisposable +{ + float Volume { get; } + bool IsPaused { get; } + bool IsStopped { get; } + bool IsKilled { get; } + int CurrentIndex { get; } + public PlayerRepeatType Repeat { get; } + bool AutoPlay { get; set; } + + void Stop(); + void Clear(); + IReadOnlyCollection GetQueuedTracks(); + IQueuedTrackInfo? GetCurrentTrack(out int index); + void Next(); + bool MoveTo(int index); + void SetVolume(int newVolume); + + void Kill(); + bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo); + + + Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync( + string query, + string queuer, + bool asNext, + MusicPlatform? forcePlatform = null); + + Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer); + bool TogglePause(); + IQueuedTrackInfo? MoveTrack(int from, int to); + void EnqueueTrack(ITrackInfo track, string queuer); + void EnqueueTracks(IEnumerable tracks, string queuer); + void SetRepeat(PlayerRepeatType type); + void ShuffleQueue(); + void SetFairplay(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IMusicQueue.cs b/src/EllieBot/Modules/Music/_common/IMusicQueue.cs new file mode 100644 index 0000000..5d4d24b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IMusicQueue.cs @@ -0,0 +1,23 @@ +namespace EllieBot.Modules.Music; + +public interface IMusicQueue +{ + int Index { get; } + int Count { get; } + IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index); + IQueuedTrackInfo EnqueueNext(ITrackInfo song, string queuer, out int index); + + void EnqueueMany(IEnumerable tracks, string queuer); + + public IReadOnlyCollection List(); + IQueuedTrackInfo? GetCurrent(out int index); + void Advance(); + void Clear(); + bool SetIndex(int index); + bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent); + void RemoveCurrent(); + IQueuedTrackInfo? MoveTrack(int from, int to); + void Shuffle(Random rng); + bool IsLast(); + void ReorderFairly(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs b/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs new file mode 100644 index 0000000..fa282ed --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Music; + +public interface IPlatformQueryResolver +{ + Task ResolveByQueryAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs b/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs new file mode 100644 index 0000000..5093cd9 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface IQueuedTrackInfo : ITrackInfo +{ + public ITrackInfo TrackInfo { get; } + + public string Queuer { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IRadioResolver.cs b/src/EllieBot/Modules/Music/_common/IRadioResolver.cs new file mode 100644 index 0000000..86a6ba5 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IRadioResolver.cs @@ -0,0 +1,6 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface IRadioResolver : IPlatformQueryResolver +{ +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackCacher.cs b/src/EllieBot/Modules/Music/_common/ITrackCacher.cs new file mode 100644 index 0000000..d55cd65 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackCacher.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Modules.Music; + +public interface ITrackCacher +{ + Task GetOrCreateStreamLink( + string id, + MusicPlatform platform, + Func> streamUrlFactory); + + Task CacheTrackDataAsync(ICachableTrackData data); + Task GetCachedDataByIdAsync(string id, MusicPlatform platform); + Task GetCachedDataByQueryAsync(string query, MusicPlatform platform); + Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data); + + Task CacheStreamUrlAsync( + string id, + MusicPlatform platform, + string url, + TimeSpan expiry); + + Task> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform); + Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable ids); + Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId); + Task GetPlaylistIdByQueryAsync(string query, MusicPlatform platform); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackInfo.cs b/src/EllieBot/Modules/Music/_common/ITrackInfo.cs new file mode 100644 index 0000000..347e8fa --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackInfo.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Modules.Music; + +public interface ITrackInfo +{ + public string Id => string.Empty; + public string Title { get; } + public string Url { get; } + public string Thumbnail { get; } + public TimeSpan Duration { get; } + public MusicPlatform Platform { get; } + public ValueTask GetStreamUrl(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs b/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs new file mode 100644 index 0000000..ae3d1e6 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Music; + +public interface ITrackResolveProvider +{ + Task QuerySongAsync(string query, MusicPlatform? forcePlatform); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs b/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs new file mode 100644 index 0000000..d88e51c --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs @@ -0,0 +1,15 @@ +#nullable disable +using EllieBot.Voice; + +namespace EllieBot.Modules.Music; + +public interface IVoiceProxy +{ + VoiceProxy.VoiceProxyState State { get; } + public bool SendPcmFrame(VoiceClient vc, Span data, int length); + public void SetGateway(VoiceGateway gateway); + Task StartSpeakingAsync(); + Task StopSpeakingAsync(); + public Task StartGateway(); + Task StopGateway(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs new file mode 100644 index 0000000..433012d --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs @@ -0,0 +1,11 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Music; + +public interface IYoutubeResolver : IPlatformQueryResolver +{ + public Regex YtVideoIdRegex { get; } + public Task ResolveByIdAsync(string id); + IAsyncEnumerable ResolveTracksFromPlaylistAsync(string query); + Task ResolveByQueryAsync(string query, bool tryExtractingId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs b/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs new file mode 100644 index 0000000..4e663ad --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Music; + +public sealed class CachableTrackData : ICachableTrackData +{ + public string Title { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string Thumbnail { get; set; } = string.Empty; + public double TotalDurationMs { get; set; } + + [JsonIgnore] + public TimeSpan Duration + => TimeSpan.FromMilliseconds(TotalDurationMs); + + public MusicPlatform Platform { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs b/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs new file mode 100644 index 0000000..9c8a9a3 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs @@ -0,0 +1,95 @@ +#nullable disable +using System.Runtime.InteropServices; + +namespace EllieBot.Modules.Music.Common; + +public sealed class MultimediaTimer : IDisposable +{ + private LpTimeProcDelegate lpTimeProc; + private readonly uint _eventId; + private readonly Action _callback; + private readonly object _state; + + public MultimediaTimer(Action callback, object state, int period) + { + if (period <= 0) + throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0"); + + _callback = callback; + _state = state; + + lpTimeProc = CallbackInternal; + _eventId = timeSetEvent((uint)period, 1, lpTimeProc, 0, TimerMode.Periodic); + } + + /// + /// The timeSetEvent function starts a specified timer event. The multimedia timer runs in its own thread. + /// After the event is activated, it calls the specified callback function or sets or pulses the specified + /// event object. + /// + /// + /// Event delay, in milliseconds. If this value is not in the range of the minimum and + /// maximum event delays supported by the timer, the function returns an error. + /// + /// + /// Resolution of the timer event, in milliseconds. The resolution increases with + /// smaller values; a resolution of 0 indicates periodic events should occur with the greatest possible accuracy. + /// To reduce system overhead, however, you should use the maximum value appropriate for your application. + /// + /// + /// Pointer to a callback function that is called once upon expiration of a single event or periodically upon + /// expiration of periodic events. If fuEvent specifies the TIME_CALLBACK_EVENT_SET or TIME_CALLBACK_EVENT_PULSE + /// flag, then the lpTimeProc parameter is interpreted as a handle to an event object. The event will be set or + /// pulsed upon completion of a single event or periodically upon completion of periodic events. + /// For any other value of fuEvent, the lpTimeProc parameter is a pointer to a callback function of type + /// LPTIMECALLBACK. + /// + /// User-supplied callback data. + /// + /// Timer event type. This parameter may include one of the following values. + [DllImport("Winmm.dll")] + private static extern uint timeSetEvent( + uint uDelay, + uint uResolution, + LpTimeProcDelegate lpTimeProc, + int dwUser, + TimerMode fuEvent); + + /// + /// The timeKillEvent function cancels a specified timer event. + /// + /// + /// Identifier of the timer event to cancel. + /// This identifier was returned by the timeSetEvent function when the timer event was set up. + /// + /// Returns TIMERR_NOERROR if successful or MMSYSERR_INVALPARAM if the specified timer event does not exist. + [DllImport("Winmm.dll")] + private static extern int timeKillEvent(uint uTimerId); + + private void CallbackInternal( + uint uTimerId, + uint uMsg, + int dwUser, + int dw1, + int dw2) + => _callback(_state); + + public void Dispose() + { + lpTimeProc = default; + timeKillEvent(_eventId); + } + + private delegate void LpTimeProcDelegate( + uint uTimerId, + uint uMsg, + int dwUser, + int dw1, + int dw2); + + private enum TimerMode + { + OneShot, + Periodic + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs new file mode 100644 index 0000000..cab883b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs @@ -0,0 +1,57 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public static class MusicExtensions +{ + public static string PrettyTotalTime(this IMusicPlayer mp) + { + long sum = 0; + foreach (var track in mp.GetQueuedTracks()) + { + if (track.Duration == TimeSpan.MaxValue) + return "∞"; + + sum += track.Duration.Ticks; + } + + var total = new TimeSpan(sum); + + return total.ToString(@"hh\:mm\:ss"); + } + + public static string PrettyVolume(this IMusicPlayer mp) + => $"🔉 {(int)(mp.Volume * 100)}%"; + + public static string PrettyName(this ITrackInfo trackInfo) + => $"**[{trackInfo.Title.TrimTo(60).Replace("[", "\\[").Replace("]", "\\]")}]({trackInfo.Url.TrimTo(50, true)})**"; + + public static string PrettyInfo(this IQueuedTrackInfo trackInfo) + => $"{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {trackInfo.Queuer}"; + + public static string PrettyFullName(this IQueuedTrackInfo trackInfo) + => $@"{trackInfo.PrettyName()} + `{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {Format.Sanitize(trackInfo.Queuer.TrimTo(15))}`"; + + public static string PrettyTotalTime(this ITrackInfo trackInfo) + { + if (trackInfo.Duration == TimeSpan.Zero) + return "(?)"; + if (trackInfo.Duration == TimeSpan.MaxValue) + return "∞"; + if (trackInfo.Duration.TotalHours >= 1) + return trackInfo.Duration.ToString("""hh\:mm\:ss"""); + + return trackInfo.Duration.ToString("""mm\:ss"""); + } + + public static ICachableTrackData ToCachedData(this ITrackInfo trackInfo, string id) + => new CachableTrackData + { + TotalDurationMs = trackInfo.Duration.TotalMilliseconds, + Id = id, + Thumbnail = trackInfo.Thumbnail, + Url = trackInfo.Url, + Platform = trackInfo.Platform, + Title = trackInfo.Title + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs new file mode 100644 index 0000000..0d6c149 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public enum MusicPlatform +{ + Radio, + Youtube, + Local, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs new file mode 100644 index 0000000..6819d4e --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs @@ -0,0 +1,531 @@ +using EllieBot.Voice; +using EllieBot.Db.Models; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace EllieBot.Modules.Music; + +public sealed class MusicPlayer : IMusicPlayer +{ + public event Func? OnCompleted; + public event Func? OnStarted; + public event Func? OnQueueStopped; + public bool IsKilled { get; private set; } + public bool IsStopped { get; private set; } + public bool IsPaused { get; private set; } + public PlayerRepeatType Repeat { get; private set; } + + public int CurrentIndex + => _queue.Index; + + public float Volume { get; private set; } = 1.0f; + + private readonly AdjustVolumeDelegate _adjustVolume; + private readonly VoiceClient _vc; + + private readonly IMusicQueue _queue; + private readonly ITrackResolveProvider _trackResolveProvider; + private readonly IVoiceProxy _proxy; + private readonly IGoogleApiService _googleApiService; + private readonly ISongBuffer _songBuffer; + + private bool skipped; + private int? forceIndex; + private readonly Thread _thread; + private readonly Random _rng; + + public bool AutoPlay { get; set; } + + public MusicPlayer( + IMusicQueue queue, + ITrackResolveProvider trackResolveProvider, + IVoiceProxy proxy, + IGoogleApiService googleApiService, + QualityPreset qualityPreset, + bool autoPlay) + { + _queue = queue; + _trackResolveProvider = trackResolveProvider; + _proxy = proxy; + _googleApiService = googleApiService; + AutoPlay = autoPlay; + _rng = new EllieRandom(); + + _vc = GetVoiceClient(qualityPreset); + if (_vc.BitDepth == 16) + _adjustVolume = AdjustVolumeInt16; + else + _adjustVolume = AdjustVolumeFloat32; + + _songBuffer = new PoopyBufferImmortalized(_vc.InputLength); + + _thread = new(async () => { await PlayLoop(); }); + _thread.Start(); + } + + private static VoiceClient GetVoiceClient(QualityPreset qualityPreset) + => qualityPreset switch + { + QualityPreset.Highest => new(), + QualityPreset.High => new(SampleRate._48k, Bitrate._128k, Channels.Two, FrameDelay.Delay40), + QualityPreset.Medium => new(SampleRate._48k, + Bitrate._96k, + Channels.Two, + FrameDelay.Delay40, + BitDepthEnum.UInt16), + QualityPreset.Low => new(SampleRate._48k, + Bitrate._64k, + Channels.Two, + FrameDelay.Delay40, + BitDepthEnum.UInt16), + _ => throw new ArgumentOutOfRangeException(nameof(qualityPreset), qualityPreset, null) + }; + + private async Task PlayLoop() + { + var sw = new Stopwatch(); + + while (!IsKilled) + { + // wait until a song is available in the queue + // or until the queue is resumed + var track = _queue.GetCurrent(out var index); + + if (track is null || IsStopped) + { + await Task.Delay(500); + continue; + } + + if (skipped) + { + skipped = false; + _queue.Advance(); + continue; + } + + using var cancellationTokenSource = new CancellationTokenSource(); + var token = cancellationTokenSource.Token; + try + { + // light up green in vc + _ = _proxy.StartSpeakingAsync(); + + _ = OnStarted?.Invoke(this, track, index); + + // make sure song buffer is ready to be (re)used + _songBuffer.Reset(); + + var streamUrl = await track.GetStreamUrl(); + // start up the data source + using var source = FfmpegTrackDataSource.CreateAsync( + _vc.BitDepth, + streamUrl, + track.Platform == MusicPlatform.Local); + + // start moving data from the source into the buffer + // this method will return once the sufficient prebuffering is done + await _songBuffer.BufferAsync(source, token); + + // // Implemenation with multimedia timer. Works but a hassle because no support for switching + // // vcs, as any error in copying will cancel the song. Also no idea how to use this as an option + // // for selfhosters. + // if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + // { + // var cancelSource = new CancellationTokenSource(); + // var cancelToken = cancelSource.Token; + // using var timer = new MultimediaTimer(_ => + // { + // if (IsStopped || IsKilled) + // { + // cancelSource.Cancel(); + // return; + // } + // + // if (_skipped) + // { + // _skipped = false; + // cancelSource.Cancel(); + // return; + // } + // + // if (IsPaused) + // return; + // + // try + // { + // // this should tolerate certain number of errors + // var result = CopyChunkToOutput(_songBuffer, _vc); + // if (!result) + // cancelSource.Cancel(); + // + // } + // catch (Exception ex) + // { + // Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message); + // cancelSource.Cancel(); + // } + // + // }, null, 20); + // + // while(true) + // await Task.Delay(1000, cancelToken); + // } + + // start sending data + var ticksPerMs = 1000f / Stopwatch.Frequency; + sw.Start(); + Thread.Sleep(2); + + var delay = sw.ElapsedTicks * ticksPerMs > 3f ? _vc.Delay - 16 : _vc.Delay - 3; + + var errorCount = 0; + while (!IsStopped && !IsKilled) + { + // doing the skip this way instead of in the condition + // ensures that a song will for sure be skipped + if (skipped) + { + skipped = false; + break; + } + + if (IsPaused) + { + await Task.Delay(200); + continue; + } + + sw.Restart(); + var ticks = sw.ElapsedTicks; + try + { + var result = CopyChunkToOutput(_songBuffer, _vc); + + // if song is finished + if (result is null) + break; + + if (result is true) + { + if (errorCount > 0) + { + _ = _proxy.StartSpeakingAsync(); + errorCount = 0; + } + + // FUTURE windows multimedia api + + // wait for slightly less than the latency + Thread.Sleep(delay); + + // and then spin out the rest + while ((sw.ElapsedTicks - ticks) * ticksPerMs <= _vc.Delay - 0.1f) + Thread.SpinWait(100); + } + else + { + // result is false is either when the gateway is being swapped + // or if the bot is reconnecting, or just disconnected for whatever reason + + // tolerate up to 15x200ms of failures (3 seconds) + if (++errorCount <= 15) + { + await Task.Delay(200); + continue; + } + + Log.Warning("Can't send data to voice channel"); + + IsStopped = true; + // if errors are happening for more than 3 seconds + // Stop the player + break; + } + } + catch (Exception ex) + { + Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message); + } + } + } + catch (Win32Exception) + { + IsStopped = true; + Log.Error("Please install ffmpeg and make sure it's added to your " + + "PATH environment variable before trying again"); + } + catch (OperationCanceledException) + { + Log.Information("Song skipped"); + } + catch (Exception ex) + { + Log.Error(ex, "Unknown error in music loop: {ErrorMessage}", ex.Message); + } + finally + { + cancellationTokenSource.Cancel(); + // turn off green in vc + + _ = OnCompleted?.Invoke(this, track); + + if (AutoPlay && track.Platform == MusicPlatform.Youtube) + { + try + { + var relatedSongs = await _googleApiService.GetRelatedVideosAsync(track.TrackInfo.Id, 5); + var related = relatedSongs.Shuffle().FirstOrDefault(); + if (related is not null) + { + var relatedTrack = + await _trackResolveProvider.QuerySongAsync(related, MusicPlatform.Youtube); + if (relatedTrack is not null) + EnqueueTrack(relatedTrack, "Autoplay"); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Failed queueing a related song via autoplay"); + } + } + + + HandleQueuePostTrack(); + skipped = false; + + _ = _proxy.StopSpeakingAsync(); + + await Task.Delay(100); + } + } + } + + private bool? CopyChunkToOutput(ISongBuffer sb, VoiceClient vc) + { + var data = sb.Read(vc.InputLength, out var length); + + // if nothing is read from the buffer, song is finished + if (data.Length == 0) + return null; + + _adjustVolume(data, Volume); + return _proxy.SendPcmFrame(vc, data, length); + } + + private void HandleQueuePostTrack() + { + if (forceIndex is { } index) + { + _queue.SetIndex(index); + forceIndex = null; + return; + } + + var (repeat, isStopped) = (Repeat, IsStopped); + + if (repeat == PlayerRepeatType.Track || isStopped) + return; + + // if queue is being repeated, advance no matter what + if (repeat == PlayerRepeatType.None) + { + // if this is the last song, + // stop the queue + if (_queue.IsLast()) + { + IsStopped = true; + OnQueueStopped?.Invoke(this); + return; + } + + _queue.Advance(); + return; + } + + _queue.Advance(); + } + + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AdjustVolumeInt16(Span audioSamples, float volume) + { + if (Math.Abs(volume - 1f) < 0.0001f) + return; + + var samples = MemoryMarshal.Cast(audioSamples); + + for (var i = 0; i < samples.Length; i++) + { + ref var sample = ref samples[i]; + sample = (short)(sample * volume); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AdjustVolumeFloat32(Span audioSamples, float volume) + { + if (Math.Abs(volume - 1f) < 0.0001f) + return; + + var samples = MemoryMarshal.Cast(audioSamples); + + for (var i = 0; i < samples.Length; i++) + { + ref var sample = ref samples[i]; + sample *= volume; + } + } + + public async Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync( + string query, + string queuer, + bool asNext, + MusicPlatform? forcePlatform = null) + { + var song = await _trackResolveProvider.QuerySongAsync(query, forcePlatform); + if (song is null) + return default; + + int index; + + if (asNext) + return (_queue.EnqueueNext(song, queuer, out index), index); + + return (_queue.Enqueue(song, queuer, out index), index); + } + + public async Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer) + { + var errorCount = 0; + foreach (var chunk in queries.Chunk(5)) + { + if (IsKilled) + break; + + await chunk.Select(async data => + { + var (query, platform) = data; + try + { + await TryEnqueueTrackAsync(query, queuer, false, platform); + errorCount = 0; + } + catch (Exception ex) + { + Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query); + ++errorCount; + } + }) + .WhenAll(); + + await Task.Delay(1000); + + // > 10 errors in a row = kill + if (errorCount > 10) + break; + } + } + + public void EnqueueTrack(ITrackInfo track, string queuer) + => _queue.Enqueue(track, queuer, out _); + + public void EnqueueTracks(IEnumerable tracks, string queuer) + => _queue.EnqueueMany(tracks, queuer); + + public void SetRepeat(PlayerRepeatType type) + => Repeat = type; + + public void ShuffleQueue() + => _queue.Shuffle(_rng); + + public void Stop() + => IsStopped = true; + + public void Clear() + { + _queue.Clear(); + skipped = true; + } + + public IReadOnlyCollection GetQueuedTracks() + => _queue.List(); + + public IQueuedTrackInfo? GetCurrentTrack(out int index) + => _queue.GetCurrent(out index); + + public void Next() + { + skipped = true; + IsStopped = false; + IsPaused = false; + } + + public bool MoveTo(int index) + { + if (_queue.SetIndex(index)) + { + forceIndex = index; + skipped = true; + IsStopped = false; + IsPaused = false; + return true; + } + + return false; + } + + public void SetVolume(int newVolume) + { + var normalizedVolume = newVolume / 100f; + if (normalizedVolume is < 0f or > 1f) + throw new ArgumentOutOfRangeException(nameof(newVolume), "Volume must be in range 0-100"); + + Volume = normalizedVolume; + } + + public void Kill() + { + IsKilled = true; + IsStopped = true; + IsPaused = false; + skipped = true; + } + + public bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo) + { + if (!_queue.TryRemoveAt(index, out trackInfo, out var isCurrent)) + return false; + + if (isCurrent) + skipped = true; + + return true; + } + + public bool TogglePause() + => IsPaused = !IsPaused; + + public IQueuedTrackInfo? MoveTrack(int from, int to) + => _queue.MoveTrack(from, to); + + public void Dispose() + { + IsKilled = true; + OnCompleted = null; + OnStarted = null; + OnQueueStopped = null; + _queue.Clear(); + _songBuffer.Dispose(); + _vc.Dispose(); + } + + private delegate void AdjustVolumeDelegate(Span data, float volume); + + public void SetFairplay() + { + _queue.ReorderFairly(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs new file mode 100644 index 0000000..1b1ce9c --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs @@ -0,0 +1,345 @@ +namespace EllieBot.Modules.Music; + +public sealed partial class MusicQueue +{ + private sealed class QueuedTrackInfo : IQueuedTrackInfo + { + public ITrackInfo TrackInfo { get; } + public string Queuer { get; } + + public string Title + => TrackInfo.Title; + + public string Url + => TrackInfo.Url; + + public string Thumbnail + => TrackInfo.Thumbnail; + + public TimeSpan Duration + => TrackInfo.Duration; + + public MusicPlatform Platform + => TrackInfo.Platform; + + + public QueuedTrackInfo(ITrackInfo trackInfo, string queuer) + { + TrackInfo = trackInfo; + Queuer = queuer; + } + + public ValueTask GetStreamUrl() + => TrackInfo.GetStreamUrl(); + } +} + +public sealed partial class MusicQueue : IMusicQueue +{ + public int Index + { + get + { + // just make sure the internal logic runs first + // to make sure that some potential intermediate value is not returned + lock (_locker) + { + return index; + } + } + } + + public int Count + { + get + { + lock (_locker) + { + return tracks.Count; + } + } + } + + private LinkedList tracks; + + private int index; + + private readonly object _locker = new(); + + public MusicQueue() + { + index = 0; + tracks = new(); + } + + public IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int enqueuedAt) + { + lock (_locker) + { + var added = new QueuedTrackInfo(trackInfo, queuer); + enqueuedAt = tracks.Count; + tracks.AddLast(added); + + return added; + } + } + + + public IQueuedTrackInfo EnqueueNext(ITrackInfo trackInfo, string queuer, out int trackIndex) + { + lock (_locker) + { + if (tracks.Count == 0) + return Enqueue(trackInfo, queuer, out trackIndex); + + var currentNode = tracks.First!; + int i; + for (i = 1; i <= index; i++) + currentNode = currentNode.Next!; // can't be null because index is always in range of the count + + var added = new QueuedTrackInfo(trackInfo, queuer); + trackIndex = i; + + tracks.AddAfter(currentNode, added); + + return added; + } + } + + public void EnqueueMany(IEnumerable toEnqueue, string queuer) + { + lock (_locker) + { + foreach (var track in toEnqueue) + { + var added = new QueuedTrackInfo(track, queuer); + tracks.AddLast(added); + } + } + } + + public IReadOnlyCollection List() + { + lock (_locker) + { + return tracks.ToList(); + } + } + + public IQueuedTrackInfo? GetCurrent(out int currentIndex) + { + lock (_locker) + { + currentIndex = index; + return tracks.ElementAtOrDefault(index); + } + } + + public void Advance() + { + lock (_locker) + { + if (++index >= tracks.Count) + index = 0; + } + } + + public void Clear() + { + lock (_locker) + { + tracks.Clear(); + } + } + + public bool SetIndex(int newIndex) + { + lock (_locker) + { + if (newIndex < 0 || newIndex >= tracks.Count) + return false; + + index = newIndex; + return true; + } + } + + private void RemoveAtInternal(int remoteAtIndex, out IQueuedTrackInfo trackInfo) + { + var removedNode = tracks.First!; + int i; + for (i = 0; i < remoteAtIndex; i++) + removedNode = removedNode.Next!; + + trackInfo = removedNode.Value; + tracks.Remove(removedNode); + + if (i <= index) + --index; + + if (index < 0) + index = Count; + + // if it was the last song in the queue + // // wrap back to start + // if (_index == Count) + // _index = 0; + // else if (i <= _index) + // if (_index == 0) + // _index = Count; + // else --_index; + } + + public void RemoveCurrent() + { + lock (_locker) + { + if (index < tracks.Count) + RemoveAtInternal(index, out _); + } + } + + public IQueuedTrackInfo? MoveTrack(int from, int to) + { + ArgumentOutOfRangeException.ThrowIfNegative(from); + ArgumentOutOfRangeException.ThrowIfNegative(to); + ArgumentOutOfRangeException.ThrowIfEqual(to, from); + + lock (_locker) + { + if (from >= Count || to >= Count) + return null; + + // update current track index + if (from == index) + { + // if the song being moved is the current track + // it means that it will for sure end up on the destination + index = to; + } + else + { + // moving a track from below the current track means + // means it will drop down + if (from < index) + index--; + + // moving a track to below the current track + // means it will rise up + if (to <= index) + index++; + + + // if both from and to are below _index - net change is + 1 - 1 = 0 + // if from is below and to is above - net change is -1 (as the track is taken and put above) + // if from is above and to is below - net change is 1 (as the track is inserted under) + // if from is above and to is above - net change is 0 + } + + // get the node which needs to be moved + var fromNode = tracks.First!; + for (var i = 0; i < from; i++) + fromNode = fromNode.Next!; + + // remove it from the queue + tracks.Remove(fromNode); + + // if it needs to be added as a first node, + // add it directly and return + if (to == 0) + { + tracks.AddFirst(fromNode); + return fromNode.Value; + } + + // else find the node at the index before the specified target + var addAfterNode = tracks.First!; + for (var i = 1; i < to; i++) + addAfterNode = addAfterNode.Next!; + + // and add after it + tracks.AddAfter(addAfterNode, fromNode); + return fromNode.Value; + } + } + + public void Shuffle(Random rng) + { + lock (_locker) + { + var list = tracks.ToArray(); + rng.Shuffle(list); + tracks = new(list); + } + } + + public bool IsLast() + { + lock (_locker) + { + return index == tracks.Count // if there are no tracks + || index == tracks.Count - 1; + } + } + + public void ReorderFairly() + { + lock (_locker) + { + var groups = new Dictionary(); + var queuers = new List>(); + + foreach (var track in tracks.Skip(index).Concat(tracks.Take(index))) + { + if (!groups.TryGetValue(track.Queuer, out var qIndex)) + { + queuers.Add(new Queue()); + qIndex = queuers.Count - 1; + groups.Add(track.Queuer, qIndex); + } + + queuers[qIndex].Enqueue(track); + } + + tracks = new LinkedList(); + index = 0; + + while (true) + { + for (var i = 0; i < queuers.Count; i++) + { + var queue = queuers[i]; + tracks.AddLast(queue.Dequeue()); + + if (queue.Count == 0) + { + queuers.RemoveAt(i); + i--; + } + } + + if (queuers.Count == 0) + break; + } + } + } + + public bool TryRemoveAt(int remoteAt, out IQueuedTrackInfo? trackInfo, out bool isCurrent) + { + lock (_locker) + { + isCurrent = false; + trackInfo = null; + + if (remoteAt < 0 || remoteAt >= tracks.Count) + return false; + + if (remoteAt == index) + isCurrent = true; + + RemoveAtInternal(remoteAt, out trackInfo); + + return true; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs new file mode 100644 index 0000000..b002779 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs @@ -0,0 +1,16 @@ +namespace EllieBot.Modules.Music; + +public sealed record RemoteTrackInfo( + string Id, + string Title, + string Url, + string Thumbnail, + TimeSpan Duration, + MusicPlatform Platform, + Func> _streamFactory) : ITrackInfo +{ + private readonly Func> _streamFactory = _streamFactory; + + public async ValueTask GetStreamUrl() + => await _streamFactory(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs new file mode 100644 index 0000000..9ae1c30 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs @@ -0,0 +1,30 @@ +namespace EllieBot.Modules.Music; + +public sealed class SimpleTrackInfo : ITrackInfo +{ + public string Title { get; } + public string Url { get; } + public string Thumbnail { get; } + public TimeSpan Duration { get; } + public MusicPlatform Platform { get; } + public string? StreamUrl { get; } + + public SimpleTrackInfo( + string title, + string url, + string thumbnail, + TimeSpan duration, + MusicPlatform platform, + string streamUrl) + { + Title = title; + Url = url; + Thumbnail = thumbnail; + Duration = duration; + Platform = platform; + StreamUrl = streamUrl; + } + + public ValueTask GetStreamUrl() + => new(StreamUrl); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs b/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs new file mode 100644 index 0000000..2dcffcc --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs @@ -0,0 +1,105 @@ +namespace EllieBot.Modules.Music; + +public sealed class TrackCacher : ITrackCacher +{ + private readonly IBotCache _cache; + + public TrackCacher(IBotCache cache) + => _cache = cache; + + + private TypedKey GetStreamLinkKey(MusicPlatform platform, string id) + => new($"music:stream:{platform}:{id}"); + + public async Task GetOrCreateStreamLink( + string id, + MusicPlatform platform, + Func> streamUrlFactory) + { + var key = GetStreamLinkKey(platform, id); + + var streamUrl = await _cache.GetOrDefaultAsync(key); + await _cache.RemoveAsync(key); + + if (streamUrl == default) + { + (streamUrl, _) = await streamUrlFactory(); + } + + // make a new one for later use + _ = Task.Run(async () => + { + (streamUrl, var expiry) = await streamUrlFactory(); + await CacheStreamUrlAsync(id, platform, streamUrl, expiry); + }); + + return streamUrl; + } + + public async Task CacheStreamUrlAsync( + string id, + MusicPlatform platform, + string url, + TimeSpan expiry) + => await _cache.AddAsync(GetStreamLinkKey(platform, id), url, expiry); + + // track data by id + private TypedKey GetTrackDataKey(MusicPlatform platform, string id) + => new($"music:track:{platform}:{id}"); + public async Task CacheTrackDataAsync(ICachableTrackData data) + => await _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data)); + + private CachableTrackData ToCachableTrackData(ICachableTrackData data) + => new CachableTrackData() + { + Id = data.Id, + Platform = data.Platform, + Thumbnail = data.Thumbnail, + Title = data.Title, + Url = data.Url, + }; + + public async Task GetCachedDataByIdAsync(string id, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetTrackDataKey(platform, id)); + + + // track data by query + private TypedKey GetTrackDataQueryKey(MusicPlatform platform, string query) + => new($"music:track:{platform}:q:{query}"); + + public async Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data) + => await Task.WhenAll( + _cache.AddAsync(GetTrackDataQueryKey(data.Platform, query), ToCachableTrackData(data)).AsTask(), + _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data)).AsTask()); + + public async Task GetCachedDataByQueryAsync(string query, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetTrackDataQueryKey(platform, query)); + + + // playlist track ids by playlist id + private TypedKey> GetPlaylistTracksCacheKey(string playlist, MusicPlatform platform) + => new($"music:playlist_tracks:{platform}:{playlist}"); + + public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable ids) + => await _cache.AddAsync(GetPlaylistTracksCacheKey(playlistId, platform), ids.ToList()); + + public async Task> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform) + { + var result = await _cache.GetAsync(GetPlaylistTracksCacheKey(playlistId, platform)); + if (result.TryGetValue(out var val)) + return val; + + return Array.Empty(); + } + + + // playlist id by query + private TypedKey GetPlaylistCacheKey(string query, MusicPlatform platform) + => new($"music:playlist_id:{platform}:{query}"); + + public async Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId) + => await _cache.AddAsync(GetPlaylistCacheKey(query, platform), playlistId); + + public async Task GetPlaylistIdByQueryAsync(string query, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetPlaylistCacheKey(query, platform)); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs b/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs new file mode 100644 index 0000000..08bb8b8 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs @@ -0,0 +1,102 @@ +#nullable disable +using EllieBot.Voice; +using EllieBot.Voice.Models; + +namespace EllieBot.Modules.Music; + +public sealed class VoiceProxy : IVoiceProxy +{ + public enum VoiceProxyState + { + Created, + Started, + Stopped + } + + private const int MAX_ERROR_COUNT = 20; + private const int DELAY_ON_ERROR_MILISECONDS = 200; + + public VoiceProxyState State + => gateway switch + { + { Started: true, Stopped: false } => VoiceProxyState.Started, + { Stopped: false } => VoiceProxyState.Created, + _ => VoiceProxyState.Stopped + }; + + + private VoiceGateway gateway; + + public VoiceProxy(VoiceGateway initial) + => gateway = initial; + + public bool SendPcmFrame(VoiceClient vc, Span data, int length) + { + try + { + var gw = gateway; + if (gw is null || gw.Stopped || !gw.Started) + return false; + + vc.SendPcmFrame(gw, data, 0, length); + return true; + } + catch (Exception) + { + return false; + } + } + + public async Task RunGatewayAction(Func action) + { + var errorCount = 0; + do + { + if (State == VoiceProxyState.Stopped) + break; + + try + { + var gw = gateway; + if (gw is null || !gw.ConnectingFinished.Task.IsCompleted) + { + ++errorCount; + await Task.Delay(DELAY_ON_ERROR_MILISECONDS); + Log.Debug("Gateway is not ready"); + continue; + } + + await action(gw); + errorCount = 0; + } + catch (Exception ex) + { + ++errorCount; + await Task.Delay(DELAY_ON_ERROR_MILISECONDS); + Log.Debug(ex, "Error performing proxy gateway action"); + } + } while (errorCount is > 0 and <= MAX_ERROR_COUNT); + + return State != VoiceProxyState.Stopped && errorCount <= MAX_ERROR_COUNT; + } + + public void SetGateway(VoiceGateway newGateway) + => gateway = newGateway; + + public Task StartSpeakingAsync() + => RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.Microphone)); + + public Task StopSpeakingAsync() + => RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.None)); + + public async Task StartGateway() + => await gateway.Start(); + + public Task StopGateway() + { + if (gateway is { } gw) + return gw.StopAsync(); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs new file mode 100644 index 0000000..d728ce7 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs @@ -0,0 +1,122 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace EllieBot.Modules.Music.Resolvers; + +public sealed class LocalTrackResolver : ILocalTrackResolver +{ + private static readonly HashSet _musicExtensions = new[] + { + ".MP4", ".MP3", ".FLAC", ".OGG", ".WAV", ".WMA", ".WMV", ".AAC", ".MKV", ".WEBM", ".M4A", ".AA", ".AAX", + ".ALAC", ".AIFF", ".MOV", ".FLV", ".OGG", ".M4V" + }.ToHashSet(); + + public async Task ResolveByQueryAsync(string query) + { + if (!File.Exists(query)) + return null; + + var trackDuration = await Ffprobe.GetTrackDurationAsync(query); + return new SimpleTrackInfo(Path.GetFileNameWithoutExtension(query), + $"https://google.com?q={Uri.EscapeDataString(Path.GetFileNameWithoutExtension(query))}", + "https://cdn.discordapp.com/attachments/155726317222887425/261850914783100928/1482522077_music.png", + trackDuration, + MusicPlatform.Local, + $"\"{Path.GetFullPath(query)}\""); + } + + public async IAsyncEnumerable ResolveDirectoryAsync(string dirPath) + { + DirectoryInfo dir; + try + { + dir = new(dirPath); + } + catch (Exception ex) + { + Log.Error(ex, "Specified directory {DirectoryPath} could not be opened", dirPath); + yield break; + } + + var files = dir.EnumerateFiles() + .Where(x => + { + if (!x.Attributes.HasFlag(FileAttributes.Hidden | FileAttributes.System) + && _musicExtensions.Contains(x.Extension.ToUpperInvariant())) + return true; + return false; + }) + .ToList(); + + var firstFile = files.FirstOrDefault()?.FullName; + if (firstFile is null) + yield break; + + var firstData = await ResolveByQueryAsync(firstFile); + if (firstData is not null) + yield return firstData; + + var fileChunks = files.Skip(1).Chunk(10); + foreach (var chunk in fileChunks) + { + var part = await chunk.Select(x => ResolveByQueryAsync(x.FullName)).WhenAll(); + + // nullable reference types being annoying + foreach (var p in part) + { + if (p is null) + continue; + + yield return p; + } + } + } +} + +public static class Ffprobe +{ + public static async Task GetTrackDurationAsync(string query) + { + query = query.Replace("\"", ""); + + try + { + using var p = Process.Start(new ProcessStartInfo + { + FileName = "ffprobe", + Arguments = + $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 -- \"{query}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true + }); + + if (p is null) + return TimeSpan.Zero; + + var data = await p.StandardOutput.ReadToEndAsync(); + if (double.TryParse(data, out var seconds)) + return TimeSpan.FromSeconds(seconds); + + var errorData = await p.StandardError.ReadToEndAsync(); + if (!string.IsNullOrWhiteSpace(errorData)) + Log.Warning("Ffprobe warning for file {FileName}: {ErrorMessage}", query, errorData); + + return TimeSpan.Zero; + } + catch (Win32Exception) + { + Log.Warning("Ffprobe was likely not installed. Local song durations will show as (?)"); + } + catch (Exception ex) + { + Log.Error(ex, "Unknown exception running ffprobe; {ErrorMessage}", ex.Message); + } + + return TimeSpan.Zero; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs b/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs new file mode 100644 index 0000000..c3733a4 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs @@ -0,0 +1,106 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Music.Resolvers; + +public class RadioResolver : IRadioResolver +{ + private readonly Regex _plsRegex = new(@"File1=(?.*?)\n", RegexOptions.Compiled); + private readonly Regex _m3URegex = new(@"(?^[^#].*)", RegexOptions.Compiled | RegexOptions.Multiline); + private readonly Regex _asxRegex = new(@".*?)""", RegexOptions.Compiled); + private readonly Regex _xspfRegex = new(@"(?.*?)", RegexOptions.Compiled); + + public async Task ResolveByQueryAsync(string query) + { + if (IsRadioLink(query)) + query = await HandleStreamContainers(query); + + return new SimpleTrackInfo(query.TrimTo(50), + query, + "https://cdn.discordapp.com/attachments/155726317222887425/261850925063340032/1482522097_radio.png", + TimeSpan.MaxValue, + MusicPlatform.Radio, + query); + } + + public static bool IsRadioLink(string query) + => (query.StartsWith("http", StringComparison.InvariantCulture) + || query.StartsWith("ww", StringComparison.InvariantCulture)) + && (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf")); + + private async Task HandleStreamContainers(string query) + { + string file = null; + try + { + using var http = new HttpClient(); + file = await http.GetStringAsync(query); + } + catch + { + return query; + } + + if (query.Contains(".pls")) + { + try + { + var m = _plsRegex.Match(file); + var res = m.Groups["url"]?.ToString(); + return res?.Trim(); + } + catch + { + Log.Warning("Failed reading .pls:\n{PlsFile}", file); + return null; + } + } + + if (query.Contains(".m3u")) + { + try + { + var m = _m3URegex.Match(file); + var res = m.Groups["url"]?.ToString(); + return res?.Trim(); + } + catch + { + Log.Warning("Failed reading .m3u:\n{M3uFile}", file); + return null; + } + } + + if (query.Contains(".asx")) + { + try + { + var m = _asxRegex.Match(file); + var res = m.Groups["url"]?.ToString(); + return res?.Trim(); + } + catch + { + Log.Warning("Failed reading .asx:\n{AsxFile}", file); + return null; + } + } + + if (query.Contains(".xspf")) + { + try + { + var m = _xspfRegex.Match(file); + var res = m.Groups["url"]?.ToString(); + return res?.Trim(); + } + catch + { + Log.Warning("Failed reading .xspf:\n{XspfFile}", file); + return null; + } + } + + return query; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs b/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs new file mode 100644 index 0000000..642edf1 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs @@ -0,0 +1,49 @@ +namespace EllieBot.Modules.Music; + +public sealed class TrackResolveProvider : ITrackResolveProvider +{ + private readonly IYoutubeResolver _ytResolver; + private readonly ILocalTrackResolver _localResolver; + private readonly IRadioResolver _radioResolver; + + public TrackResolveProvider( + IYoutubeResolver ytResolver, + ILocalTrackResolver localResolver, + IRadioResolver radioResolver) + { + _ytResolver = ytResolver; + _localResolver = localResolver; + _radioResolver = radioResolver; + } + + public Task QuerySongAsync(string query, MusicPlatform? forcePlatform) + { + switch (forcePlatform) + { + case MusicPlatform.Radio: + return _radioResolver.ResolveByQueryAsync(query); + case MusicPlatform.Youtube: + return _ytResolver.ResolveByQueryAsync(query); + case MusicPlatform.Local: + return _localResolver.ResolveByQueryAsync(query); + case null: + var match = _ytResolver.YtVideoIdRegex.Match(query); + if (match.Success) + return _ytResolver.ResolveByIdAsync(match.Groups["id"].Value); + else if (Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.IsFile) + return _localResolver.ResolveByQueryAsync(uri.AbsolutePath); + else if (IsRadioLink(query)) + return _radioResolver.ResolveByQueryAsync(query); + else + return _ytResolver.ResolveByQueryAsync(query, false); + default: + Log.Error("Unsupported platform: {MusicPlatform}", forcePlatform); + return Task.FromResult(null); + } + } + + public static bool IsRadioLink(string query) + => (query.StartsWith("http", StringComparison.InvariantCulture) + || query.StartsWith("ww", StringComparison.InvariantCulture)) + && (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf")); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs new file mode 100644 index 0000000..70479d0 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs @@ -0,0 +1,315 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using EllieBot.Modules.Searches; + +namespace EllieBot.Modules.Music; + +public sealed class YtdlYoutubeResolver : IYoutubeResolver +{ + private static readonly string[] _durationFormats = + [ + "ss", "m\\:ss", "mm\\:ss", "h\\:mm\\:ss", "hh\\:mm\\:ss", "hhh\\:mm\\:ss" + ]; + + private static readonly Regex _expiryRegex = new(@"(?:[\?\&]expire\=(?\d+))"); + + + private static readonly Regex _simplePlaylistRegex = new(@"&list=(?[\w\-]{12,})", RegexOptions.Compiled); + + public Regex YtVideoIdRegex { get; } = + new(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?[a-zA-Z0-9_-]{6,11})", + RegexOptions.Compiled); + + private readonly ITrackCacher _trackCacher; + + private readonly YtdlOperation _ytdlPlaylistOperation; + private readonly YtdlOperation _ytdlIdOperation; + private readonly YtdlOperation _ytdlSearchOperation; + + private readonly IGoogleApiService _google; + + public YtdlYoutubeResolver(ITrackCacher trackCacher, IGoogleApiService google, SearchesConfigService scs) + { + _trackCacher = trackCacher; + _google = google; + + + _ytdlPlaylistOperation = new("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "-f bestaudio " + + "-e " + + "--get-url " + + "--get-id " + + "--get-thumbnail " + + "--get-duration " + + "--no-check-certificate " + + "-i " + + "--yes-playlist " + + "-- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl); + + _ytdlIdOperation = new("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "-f bestaudio " + + "-e " + + "--get-url " + + "--get-id " + + "--get-thumbnail " + + "--get-duration " + + "--no-check-certificate " + + "-- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl); + + _ytdlSearchOperation = new("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "-f bestaudio " + + "-e " + + "--get-url " + + "--get-id " + + "--get-thumbnail " + + "--get-duration " + + "--no-check-certificate " + + "--default-search " + + "\"ytsearch:\" -- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl); + } + + private YtTrackData ResolveYtdlData(string ytdlOutputString) + { + if (string.IsNullOrWhiteSpace(ytdlOutputString)) + return default; + + var dataArray = ytdlOutputString.Trim().Split('\n'); + + if (dataArray.Length < 5) + { + Log.Information("Not enough data received: {YtdlData}", ytdlOutputString); + return default; + } + + if (!TimeSpan.TryParseExact(dataArray[4], _durationFormats, CultureInfo.InvariantCulture, out var time)) + time = TimeSpan.Zero; + + var thumbnail = Uri.IsWellFormedUriString(dataArray[3], UriKind.Absolute) ? dataArray[3].Trim() : string.Empty; + + return new(dataArray[0], dataArray[1], thumbnail, dataArray[2], time); + } + + private ITrackInfo DataToInfo(in YtTrackData trackData) + => new RemoteTrackInfo( + trackData.Id, + trackData.Title, + $"https://youtube.com/watch?v={trackData.Id}", + trackData.Thumbnail, + trackData.Duration, + MusicPlatform.Youtube, + CreateCacherFactory(trackData.Id)); + + private Func> CreateCacherFactory(string id) + => () => _trackCacher.GetOrCreateStreamLink(id, + MusicPlatform.Youtube, + async () => await ExtractNewStreamUrlAsync(id)); + + private static TimeSpan GetExpiry(string streamUrl) + { + var match = _expiryRegex.Match(streamUrl); + if (match.Success && double.TryParse(match.Groups["timestamp"].ToString(), out var timestamp)) + { + var realExpiry = timestamp.ToUnixTimestamp() - DateTime.UtcNow; + if (realExpiry > TimeSpan.FromMinutes(60)) + return realExpiry.Subtract(TimeSpan.FromMinutes(30)); + + return realExpiry; + } + + return TimeSpan.FromHours(1); + } + + private async Task<(string StreamUrl, TimeSpan Expiry)> ExtractNewStreamUrlAsync(string id) + { + var data = await _ytdlIdOperation.GetDataAsync(id); + var trackInfo = ResolveYtdlData(data); + if (string.IsNullOrWhiteSpace(trackInfo.StreamUrl)) + return default; + + return (trackInfo.StreamUrl!, GetExpiry(trackInfo.StreamUrl!)); + } + + public async Task ResolveByIdAsync(string id) + { + id = id.Trim(); + + var cachedData = await _trackCacher.GetCachedDataByIdAsync(id, MusicPlatform.Youtube); + if (cachedData is null) + { + Log.Information("Resolving youtube track by Id: {YoutubeId}", id); + + var data = await _ytdlIdOperation.GetDataAsync(id); + + var trackInfo = ResolveYtdlData(data); + if (string.IsNullOrWhiteSpace(trackInfo.Title)) + return default; + + var toReturn = DataToInfo(in trackInfo); + + await Task.WhenAll(_trackCacher.CacheTrackDataAsync(toReturn.ToCachedData(id)), + CacheStreamUrlAsync(trackInfo)); + + return toReturn; + } + + return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration)); + } + + private Task CacheStreamUrlAsync(YtTrackData trackInfo) + => _trackCacher.CacheStreamUrlAsync(trackInfo.Id, + MusicPlatform.Youtube, + trackInfo.StreamUrl!, + GetExpiry(trackInfo.StreamUrl!)); + + public async IAsyncEnumerable ResolveTracksByPlaylistIdAsync(string playlistId) + { + Log.Information("Resolving youtube tracks from playlist: {PlaylistId}", playlistId); + var count = 0; + + var ids = await _trackCacher.GetPlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube); + if (ids.Count > 0) + { + foreach (var id in ids) + { + var trackInfo = await ResolveByIdAsync(id); + if (trackInfo is null) + continue; + + yield return trackInfo; + } + + yield break; + } + + var data = string.Empty; + var trackIds = new List(); + await foreach (var line in _ytdlPlaylistOperation.EnumerateDataAsync(playlistId)) + { + data += line; + + if (++count == 5) + { + var trackData = ResolveYtdlData(data); + data = string.Empty; + count = 0; + if (string.IsNullOrWhiteSpace(trackData.Id)) + continue; + + var info = DataToInfo(in trackData); + await Task.WhenAll(_trackCacher.CacheTrackDataAsync(info.ToCachedData(trackData.Id)), + CacheStreamUrlAsync(trackData)); + + trackIds.Add(trackData.Id); + yield return info; + } + else + data += Environment.NewLine; + } + + await _trackCacher.CachePlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube, trackIds); + } + + public async IAsyncEnumerable ResolveTracksFromPlaylistAsync(string query) + { + string? playlistId; + // try to match playlist id inside the query, if a playlist url has been queried + var match = _simplePlaylistRegex.Match(query); + if (match.Success) + { + // if it's a success, just return from that playlist using the id + playlistId = match.Groups["id"].ToString(); + await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId)) + yield return track; + + yield break; + } + + // if a query is a search term, try the cache + playlistId = await _trackCacher.GetPlaylistIdByQueryAsync(query, MusicPlatform.Youtube); + if (playlistId is null) + { + // if it's not in the cache + // find playlist id by keyword using google api + try + { + var playlistIds = await _google.GetPlaylistIdsByKeywordsAsync(query); + playlistId = playlistIds.FirstOrDefault(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error Getting playlist id via GoogleApi"); + } + + // if query is not a playlist url + // and query result is not in the cache + // and api returns no values + // it means invalid input has been used, + // or google api key is not provided + if (playlistId is null) + yield break; + } + + // cache the query -> playlist id for fast future lookup + await _trackCacher.CachePlaylistIdByQueryAsync(query, MusicPlatform.Youtube, playlistId); + await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId)) + yield return track; + } + + public Task ResolveByQueryAsync(string query) + => ResolveByQueryAsync(query, true); + + public async Task ResolveByQueryAsync(string query, bool tryResolving) + { + if (tryResolving) + { + var match = YtVideoIdRegex.Match(query); + if (match.Success) + return await ResolveByIdAsync(match.Groups["id"].Value); + } + + Log.Information("Resolving youtube song by search term: {YoutubeQuery}", query); + + var cachedData = await _trackCacher.GetCachedDataByQueryAsync(query, MusicPlatform.Youtube); + if (cachedData is null || string.IsNullOrWhiteSpace(cachedData.Title)) + { + var stringData = await _ytdlSearchOperation.GetDataAsync(query); + var trackData = ResolveYtdlData(stringData); + + var trackInfo = DataToInfo(trackData); + await Task.WhenAll(_trackCacher.CacheTrackDataByQueryAsync(query, trackInfo.ToCachedData(trackData.Id)), + CacheStreamUrlAsync(trackData)); + return trackInfo; + } + + return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration)); + } + + private readonly struct YtTrackData + { + public readonly string Title; + public readonly string Id; + public readonly string Thumbnail; + public readonly string? StreamUrl; + public readonly TimeSpan Duration; + + public YtTrackData( + string title, + string id, + string thumbnail, + string? streamUrl, + TimeSpan duration) + { + Title = title.Trim(); + Id = id.Trim(); + Thumbnail = thumbnail; + StreamUrl = streamUrl; + Duration = duration; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs new file mode 100644 index 0000000..d8f81b5 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs @@ -0,0 +1,27 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class MusicPlayerSettingsExtensions +{ + public static async Task ForGuildAsync(this DbSet settings, ulong guildId) + { + var toReturn = await settings.AsQueryable().FirstOrDefaultAsync(x => x.GuildId == guildId); + + if (toReturn is null) + { + var newSettings = new MusicPlayerSettings + { + GuildId = guildId, + PlayerRepeat = PlayerRepeatType.Queue + }; + + await settings.AddAsync(newSettings); + return newSettings; + } + + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs new file mode 100644 index 0000000..16a755b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class MusicPlaylist : DbEntity +{ + public string Name { get; set; } + public string Author { get; set; } + public ulong AuthorId { get; set; } + public List Songs { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs new file mode 100644 index 0000000..0e3e603 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs @@ -0,0 +1,18 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class MusicPlaylistExtensions +{ + public static List GetPlaylistsOnPage(this DbSet playlists, int num) + { + ArgumentOutOfRangeException.ThrowIfLessThan(num, 1); + + return playlists.AsQueryable().Skip((num - 1) * 20).Take(20).Include(pl => pl.Songs).ToList(); + } + + public static MusicPlaylist GetWithSongs(this DbSet playlists, int id) + => playlists.Include(mpl => mpl.Songs).FirstOrDefault(mpl => mpl.Id == id); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs b/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs new file mode 100644 index 0000000..40f8397 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs @@ -0,0 +1,61 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class MusicPlayerSettings +{ + /// + /// Auto generated Id + /// + public int Id { get; set; } + + /// + /// Id of the guild + /// + public ulong GuildId { get; set; } + + /// + /// Queue repeat type + /// + public PlayerRepeatType PlayerRepeat { get; set; } = PlayerRepeatType.Queue; + + /// + /// Channel id the bot will always try to send track related messages to + /// + public ulong? MusicChannelId { get; set; } + + /// + /// Default volume player will be created with + /// + public int Volume { get; set; } = 100; + + /// + /// Whether the bot should auto disconnect from the voice channel once the queue is done + /// This only has effect if + /// + public bool AutoDisconnect { get; set; } + + /// + /// Selected quality preset for the music player + /// + public QualityPreset QualityPreset { get; set; } + + /// + /// Whether the bot will automatically queue related songs + /// + public bool AutoPlay { get; set; } +} + +public enum QualityPreset +{ + Highest, + High, + Medium, + Low +} + +public enum PlayerRepeatType +{ + None, + Track, + Queue +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs b/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs new file mode 100644 index 0000000..56f166f --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs @@ -0,0 +1,35 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Modules.Patronage; + +public class PatronageConfig : ConfigServiceBase +{ + public override string Name + => "patron"; + + private static readonly TypedKey _changeKey; + + private const string FILE_PATH = "data/patron.yml"; + + public PatronageConfig(IConfigSeria serializer, IPubSub pubSub) : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("enabled", + x => x.IsEnabled, + bool.TryParse, + ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + ModifyConfig(c => + { + if (c.Version == 1) + { + c.Version = 2; + c.IsEnabled = false; + } + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs b/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs new file mode 100644 index 0000000..336fe9c --- /dev/null +++ b/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs @@ -0,0 +1,195 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Modules.Patronage; +using EllieBot.Services.Currency; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public sealed class CurrencyRewardService : IEService, IDisposable +{ + private readonly ICurrencyService _cs; + private readonly IPatronageService _ps; + private readonly DbService _db; + private readonly IMessageSenderService _sender; + private readonly GamblingConfigService _config; + private readonly DiscordSocketClient _client; + + public CurrencyRewardService( + ICurrencyService cs, + IPatronageService ps, + DbService db, + IMessageSenderService sender, + GamblingConfigService config, + DiscordSocketClient client) + { + _cs = cs; + _ps = ps; + _db = db; + _sender = sender; + _config = config; + _client = client; + + _ps.OnNewPatronPayment += OnNewPayment; + _ps.OnPatronRefunded += OnPatronRefund; + _ps.OnPatronUpdated += OnPatronUpdate; + } + + public void Dispose() + { + _ps.OnNewPatronPayment -= OnNewPayment; + _ps.OnPatronRefunded -= OnPatronRefund; + _ps.OnPatronUpdated -= OnPatronUpdate; + } + + private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron) + { + // if pledge was increased + if (oldPatron.Amount < newPatron.Amount) + { + var conf = _config.Data; + var newAmount = (long)(newPatron.Amount * conf.PatreonCurrencyPerCent); + + RewardedUser old; + await using (var ctx = _db.GetDbContext()) + { + old = await ctx.GetTable() + .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId) + .FirstOrDefaultAsync(); + + if (old is null) + { + await OnNewPayment(newPatron); + return; + } + + // no action as the amount is the same or lower + if (old.AmountRewardedThisMonth >= newAmount) + return; + + var count = await ctx.GetTable() + .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId) + .UpdateAsync(_ => new() + { + PlatformUserId = newPatron.UniquePlatformUserId, + UserId = newPatron.UserId, + // amount before bonuses + AmountRewardedThisMonth = newAmount, + LastReward = newPatron.PaidAt + }); + + // shouldn't ever happen + if (count == 0) + return; + } + + var oldAmount = old.AmountRewardedThisMonth; + + var realNewAmount = GetRealCurrencyReward( + (int)(newAmount / conf.PatreonCurrencyPerCent), + newAmount, + out var percentBonus); + + var realOldAmount = GetRealCurrencyReward( + (int)(oldAmount / conf.PatreonCurrencyPerCent), + oldAmount, + out _); + + var diff = realNewAmount - realOldAmount; + if (diff <= 0) + return; // no action if new is lower + + // if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars, + // up to 100% + + await _cs.AddAsync(newPatron.UserId, diff, new TxData("patron", "update")); + + _ = SendMessageToUser(newPatron.UserId, + $"You've received an additional **{diff}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!"); + } + } + + private long GetRealCurrencyReward(int pledgeCents, long modifiedAmount, out int percentBonus) + { + // needs at least 5$ to be eligible for a bonus + if (pledgeCents < 500) + { + percentBonus = 0; + return modifiedAmount; + } + + var dollarValue = pledgeCents / 100; + percentBonus = dollarValue switch + { + >= 100 => 100, + >= 50 => 50, + >= 20 => 20, + >= 10 => 10, + >= 5 => 5, + _ => 0 + }; + return (long)(modifiedAmount * (1 + (percentBonus / 100.0f))); + } + + // on a new payment, always give the full amount. + private async Task OnNewPayment(Patron patron) + { + var amount = (long)(patron.Amount * _config.Data.PatreonCurrencyPerCent); + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + PlatformUserId = patron.UniquePlatformUserId, + UserId = patron.UserId, + AmountRewardedThisMonth = amount, + LastReward = patron.PaidAt, + }, + old => new() + { + AmountRewardedThisMonth = amount, + UserId = patron.UserId, + LastReward = patron.PaidAt + }, + () => new() + { + PlatformUserId = patron.UniquePlatformUserId + }); + + var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus); + await _cs.AddAsync(patron.UserId, realAmount, new("patron", "new")); + _ = SendMessageToUser(patron.UserId, + $"You've received **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (**+{percentBonus}%**)!"); + } + + private async Task SendMessageToUser(ulong userId, string message) + { + try + { + var user = (IUser)_client.GetUser(userId) ?? await _client.Rest.GetUserAsync(userId); + if (user is null) + return; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(message); + + await _sender.Response(user).Embed(eb).SendAsync(); + } + catch + { + Log.Warning("Unable to send a \"Currency Reward\" message to the patron {UserId}", userId); + } + } + + private async Task OnPatronRefund(Patron patron) + { + await using var ctx = _db.GetDbContext(); + _ = await ctx.GetTable() + .UpdateAsync(old => new() + { + AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2 + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/InsufficientTier.cs b/src/EllieBot/Modules/Patronage/InsufficientTier.cs new file mode 100644 index 0000000..26a0675 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/InsufficientTier.cs @@ -0,0 +1,11 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Patronage; + +public readonly struct InsufficientTier +{ + public FeatureType FeatureType { get; init; } + public string Feature { get; init; } + public PatronTier RequiredTier { get; init; } + public PatronTier UserTier { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs new file mode 100644 index 0000000..ad65448 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs @@ -0,0 +1,150 @@ +#nullable disable +using OneOf; +using OneOf.Types; +using System.Net.Http.Json; +using System.Text.Json; + +namespace EllieBot.Modules.Patronage; + +public class PatreonClient : IDisposable +{ + private readonly string _clientId; + private readonly string _clientSecret; + private string refreshToken; + + + private string accessToken = string.Empty; + private readonly HttpClient _http; + + private DateTime refreshAt = DateTime.UtcNow; + + public PatreonClient(string clientId, string clientSecret, string refreshToken) + { + _clientId = clientId; + _clientSecret = clientSecret; + this.refreshToken = refreshToken; + + _http = new(); + } + + public void Dispose() + => _http.Dispose(); + + public PatreonCredentials GetCredentials() + => new PatreonCredentials() + { + AccessToken = accessToken, + ClientId = _clientId, + ClientSecret = _clientSecret, + RefreshToken = refreshToken, + }; + + public async Task>> RefreshTokenAsync(bool force) + { + if (!force && IsTokenValid()) + return new Success(); + + var res = await _http.PostAsync("https://www.patreon.com/api/oauth2/token" + + "?grant_type=refresh_token" + + $"&refresh_token={refreshToken}" + + $"&client_id={_clientId}" + + $"&client_secret={_clientSecret}", + null); + + if (!res.IsSuccessStatusCode) + return new Error($"Request did not return a sucess status code. Status code: {res.StatusCode}"); + + try + { + var data = await res.Content.ReadFromJsonAsync(); + + if (data is null) + return new Error($"Invalid data retrieved from Patreon."); + + refreshToken = data.RefreshToken; + accessToken = data.AccessToken; + + refreshAt = DateTime.UtcNow.AddSeconds(data.ExpiresIn - 5.Minutes().TotalSeconds); + return new Success(); + } + catch (Exception ex) + { + return new Error($"Error during deserialization: {ex.Message}"); + } + } + + private async ValueTask EnsureTokenValidAsync() + { + if (!IsTokenValid()) + { + var res = await RefreshTokenAsync(true); + return res.Match( + static _ => true, + static err => + { + Log.Warning("Error getting token: {ErrorMessage}", err.Value); + return false; + }); + } + + return true; + } + + private bool IsTokenValid() + => refreshAt > DateTime.UtcNow && !string.IsNullOrWhiteSpace(accessToken); + + public async Task>, Error>> GetMembersAsync(string campaignId) + { + if (!await EnsureTokenValidAsync()) + return new Error("Unable to get patreon token"); + + return OneOf>, Error>.FromT0( + GetMembersInternalAsync(campaignId)); + } + + private async IAsyncEnumerable> GetMembersInternalAsync(string campaignId) + { + _http.DefaultRequestHeaders.Clear(); + _http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", + $"Bearer {accessToken}"); + + var page = + $"https://www.patreon.com/api/oauth2/v2/campaigns/{campaignId}/members" + + $"?fields%5Bmember%5D=full_name,currently_entitled_amount_cents,last_charge_date,last_charge_status" + + $"&fields%5Buser%5D=social_connections" + + $"&include=user" + + $"&sort=-last_charge_date"; + PatreonMembersResponse data; + + do + { + var res = await _http.GetStreamAsync(page); + data = await JsonSerializer.DeserializeAsync(res); + + if (data is null) + break; + + var userData = data.Data + .Join(data.Included, + static m => m.Relationships.User.Data.Id, + static u => u.Id, + static (m, u) => new PatreonMemberData() + { + PatreonUserId = m.Relationships.User.Data.Id, + UserId = ulong.TryParse( + u.Attributes?.SocialConnections?.Discord?.UserId ?? string.Empty, + out var userId) + ? userId + : 0, + EntitledToCents = m.Attributes.CurrentlyEntitledAmountCents, + LastChargeDate = m.Attributes.LastChargeDate, + LastChargeStatus = m.Attributes.LastChargeStatus + }) + .Where(x => x.UserId == 140788173885276160) + .ToArray(); + + yield return userData; + + } while (!string.IsNullOrWhiteSpace(page = data.Links?.Next)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs new file mode 100644 index 0000000..5eb6f1f --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +public readonly struct PatreonCredentials +{ + public string ClientId { get; init; } + public string ClientSecret { get; init; } + public string AccessToken { get; init; } + public string RefreshToken { get; init; } +} diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs new file mode 100644 index 0000000..f5d120e --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs @@ -0,0 +1,134 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Patronage; + +public sealed class Attributes +{ + [JsonPropertyName("full_name")] + public string FullName { get; set; } + + [JsonPropertyName("is_follower")] + public bool IsFollower { get; set; } + + [JsonPropertyName("last_charge_date")] + public DateTime? LastChargeDate { get; set; } + + [JsonPropertyName("last_charge_status")] + public string LastChargeStatus { get; set; } + + [JsonPropertyName("lifetime_support_cents")] + public int LifetimeSupportCents { get; set; } + + [JsonPropertyName("currently_entitled_amount_cents")] + public int CurrentlyEntitledAmountCents { get; set; } + + [JsonPropertyName("patron_status")] + public string PatronStatus { get; set; } +} + +public sealed class Data +{ + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } +} + +public sealed class Address +{ + [JsonPropertyName("data")] + public Data Data { get; set; } +} + +// public sealed class CurrentlyEntitledTiers +// { +// [JsonPropertyName("data")] +// public List Data { get; set; } +// } + +// public sealed class Relationships +// { +// [JsonPropertyName("address")] +// public Address Address { get; set; } +// +// // [JsonPropertyName("currently_entitled_tiers")] +// // public CurrentlyEntitledTiers CurrentlyEntitledTiers { get; set; } +// } + +public sealed class PatreonMembersResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } + + [JsonPropertyName("included")] + public List Included { get; set; } + + [JsonPropertyName("links")] + public PatreonLinks Links { get; set; } +} + +public sealed class PatreonLinks +{ + [JsonPropertyName("next")] + public string Next { get; set; } +} + +public sealed class PatreonUser +{ + [JsonPropertyName("attributes")] + public PatreonUserAttributes Attributes { get; set; } + + [JsonPropertyName("id")] + public string Id { get; set; } + // public string Type { get; set; } +} + +public sealed class PatreonUserAttributes +{ + [JsonPropertyName("social_connections")] + public PatreonSocials SocialConnections { get; set; } +} + +public sealed class PatreonSocials +{ + [JsonPropertyName("discord")] + public DiscordSocial Discord { get; set; } +} + +public sealed class DiscordSocial +{ + [JsonPropertyName("user_id")] + public string UserId { get; set; } +} + +public sealed class PatreonMember +{ + [JsonPropertyName("attributes")] + public Attributes Attributes { get; set; } + + [JsonPropertyName("relationships")] + public Relationships Relationships { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } +} + +public sealed class Relationships +{ + [JsonPropertyName("user")] + public PatreonRelationshipUser User { get; set; } +} + +public sealed class PatreonRelationshipUser +{ + [JsonPropertyName("data")] + public PatreonUserData Data { get; set; } +} + +public sealed class PatreonUserData +{ + [JsonPropertyName("id")] + public string Id { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs new file mode 100644 index 0000000..58656b9 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs @@ -0,0 +1,33 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +public sealed class PatreonMemberData : ISubscriberData +{ + public string PatreonUserId { get; init; } + public ulong UserId { get; init; } + public DateTime? LastChargeDate { get; init; } + public string LastChargeStatus { get; init; } + public int EntitledToCents { get; init; } + + public string UniquePlatformUserId + => PatreonUserId; + ulong ISubscriberData.UserId + => UserId; + public int Cents + => EntitledToCents; + public DateTime? LastCharge + => LastChargeDate; + public SubscriptionChargeStatus ChargeStatus + => LastChargeStatus switch + { + "Paid" => SubscriptionChargeStatus.Paid, + "Fraud" or "Refunded" => SubscriptionChargeStatus.Refunded, + "Declined" or "Pending" => SubscriptionChargeStatus.Unpaid, + _ => SubscriptionChargeStatus.Other, + }; +} + +public sealed class PatreonPledgeData +{ + +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs new file mode 100644 index 0000000..2b6d154 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs @@ -0,0 +1,22 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Patronage; + +public sealed class PatreonRefreshData +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + [JsonPropertyName("expires_in")] + public long ExpiresIn { get; set; } + + [JsonPropertyName("scope")] + public string Scope { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs new file mode 100644 index 0000000..1fd170e --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs @@ -0,0 +1,79 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +/// +/// Service tasked with handling pledges on patreon +/// +public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService +{ + private readonly IBotCredsProvider _credsProvider; + private readonly PatreonClient _patreonClient; + + public PatreonSubscriptionHandler(IBotCredsProvider credsProvider) + { + _credsProvider = credsProvider; + var botCreds = credsProvider.GetCreds(); + _patreonClient = new PatreonClient(botCreds.Patreon.ClientId, botCreds.Patreon.ClientSecret, botCreds.Patreon.RefreshToken); + } + + public async IAsyncEnumerable> GetPatronsAsync() + { + var botCreds = _credsProvider.GetCreds(); + + if (string.IsNullOrWhiteSpace(botCreds.Patreon.CampaignId) + || string.IsNullOrWhiteSpace(botCreds.Patreon.ClientId) + || string.IsNullOrWhiteSpace(botCreds.Patreon.ClientSecret) + || string.IsNullOrWhiteSpace(botCreds.Patreon.RefreshToken)) + yield break; + + var result = await _patreonClient.RefreshTokenAsync(false); + if (!result.TryPickT0(out _, out var error)) + { + Log.Warning("Unable to refresh patreon token: {ErrorMessage}", error.Value); + yield break; + } + + var patreonCreds = _patreonClient.GetCredentials(); + + _credsProvider.ModifyCredsFile(c => + { + c.Patreon.AccessToken = patreonCreds.AccessToken; + c.Patreon.RefreshToken = patreonCreds.RefreshToken; + }); + + IAsyncEnumerable> data; + try + { + var maybeUserData = await _patreonClient.GetMembersAsync(botCreds.Patreon.CampaignId); + data = maybeUserData.Match( + static userData => userData, + static err => + { + Log.Warning("Error while getting patreon members: {ErrorMessage}", err.Value); + return AsyncEnumerable.Empty>(); + }); + } + catch (Exception ex) + { + Log.Warning(ex, + "Unexpected error while refreshing patreon members: {ErroMessage}", + ex.Message); + + yield break; + } + + var now = DateTime.UtcNow; + var firstOfThisMonth = new DateOnly(now.Year, now.Month, 1); + await foreach (var batch in data) + { + // send only active patrons + var toReturn = batch.Where(x => x.Cents > 0 + && x.LastCharge is { } lc + && lc.ToUniversalTime().ToDateOnly() >= firstOfThisMonth) + .ToArray(); + + if (toReturn.Length > 0) + yield return toReturn; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/PatronageCommands.cs b/src/EllieBot/Modules/Patronage/PatronageCommands.cs new file mode 100644 index 0000000..8d0a38a --- /dev/null +++ b/src/EllieBot/Modules/Patronage/PatronageCommands.cs @@ -0,0 +1,156 @@ +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Help; + +public partial class Help +{ + [OnlyPublicBot] + public partial class Patronage : EllieModule + { + private readonly PatronageService _service; + private readonly PatronageConfig _pConf; + + public Patronage(PatronageService service, PatronageConfig pConf) + { + _service = service; + _pConf = pConf; + } + + [Cmd] + [Priority(2)] + public Task Patron() + => InternalPatron(ctx.User); + + [Cmd] + [Priority(0)] + [OwnerOnly] + public Task Patron(IUser user) + => InternalPatron(user); + + [Cmd] + [Priority(0)] + [OwnerOnly] + public async Task PatronMessage(PatronTier tierAndHigher, string message) + { + _ = ctx.Channel.TriggerTypingAsync(); + var result = await _service.SendMessageToPatronsAsync(tierAndHigher, message); + + await Response() + .Confirm(strs.patron_msg_sent( + Format.Code(tierAndHigher.ToString()), + Format.Bold(result.Success.ToString()), + Format.Bold(result.Failed.ToString()))) + .SendAsync(); + } + + // [OwnerOnly] + // public async Task PatronGift(IUser user, int amount) + // { + // // i can't figure out a good way to gift more than one month at the moment. + // + // if (amount < 1) + // return; + // + // var patron = _service.GiftPatronAsync(user, amount); + // + // var eb = _sender.CreateEmbed(); + // + // await Response().Embed(eb.WithDescription($"Added **{days}** days of Patron benefits to {user.Mention}!") + // .AddField("Tier", Format.Bold(patron.Tier.ToString()), true) + // .AddField("Amount", $"**{patron.Amount / 100.0f:N1}$**", true) + // .AddField("Until", TimestampTag.FromDateTime(patron.ValidThru.AddDays(1)))).SendAsync(); + // + // + // } + + private async Task InternalPatron(IUser user) + { + if (!_pConf.Data.IsEnabled) + { + await Response().Error(strs.patron_not_enabled).SendAsync(); + return; + } + + var patron = await _service.GetPatronAsync(user.Id); + var quotaStats = await _service.GetUserQuotaStatistic(user.Id); + + var eb = _sender.CreateEmbed() + .WithAuthor(user) + .WithTitle(GetText(strs.patron_info)) + .WithOkColor(); + + if (quotaStats.Commands.Count == 0 + && quotaStats.Groups.Count == 0 + && quotaStats.Modules.Count == 0) + { + eb.WithDescription(GetText(strs.no_quota_found)); + } + else + { + eb.AddField(GetText(strs.tier), Format.Bold(patron.Tier.ToFullName()), true) + .AddField(GetText(strs.pledge), $"**{patron.Amount / 100.0f:N1}$**", true); + + if (patron.Tier != PatronTier.None) + eb.AddField(GetText(strs.expires), + patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), + true); + + eb.AddField(GetText(strs.quotas), "⁣", false); + + if (quotaStats.Commands.Count > 0) + { + var text = GetQuotaList(quotaStats.Commands); + if (!string.IsNullOrWhiteSpace(text)) + eb.AddField(GetText(strs.commands), text, true); + } + + if (quotaStats.Groups.Count > 0) + { + var text = GetQuotaList(quotaStats.Groups); + if (!string.IsNullOrWhiteSpace(text)) + eb.AddField(GetText(strs.groups), text, true); + } + + if (quotaStats.Modules.Count > 0) + { + var text = GetQuotaList(quotaStats.Modules); + if (!string.IsNullOrWhiteSpace(text)) + eb.AddField(GetText(strs.modules), text, true); + } + } + + + try + { + await Response().User(ctx.User).Embed(eb).SendAsync(); + _ = ctx.OkAsync(); + } + catch + { + await Response().Error(strs.cant_dm).SendAsync(); + } + } + + private string GetQuotaList(IReadOnlyDictionary featureQuotaStats) + { + var text = string.Empty; + foreach (var (key, q) in featureQuotaStats) + { + text += $"\n⁣\t`{key}`\n"; + if (q.Hourly != default) + text += $"⁣ ⁣ {GetEmoji(q.Hourly)} {q.Hourly.Cur}/{q.Hourly.Max} per hour\n"; + if (q.Daily != default) + text += $"⁣ ⁣ {GetEmoji(q.Daily)} {q.Daily.Cur}/{q.Daily.Max} per day\n"; + if (q.Monthly != default) + text += $"⁣ ⁣ {GetEmoji(q.Monthly)} {q.Monthly.Cur}/{q.Monthly.Max} per month\n"; + } + + return text; + } + + private string GetEmoji((uint Cur, uint Max) limit) + => limit.Cur < limit.Max + ? "✅" + : "⚠️"; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/PatronageService.cs b/src/EllieBot/Modules/Patronage/PatronageService.cs new file mode 100644 index 0000000..3d5f62c --- /dev/null +++ b/src/EllieBot/Modules/Patronage/PatronageService.cs @@ -0,0 +1,843 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using OneOf; +using OneOf.Types; +using CommandInfo = Discord.Commands.CommandInfo; + +namespace EllieBot.Modules.Patronage; + +/// +public sealed class PatronageService + : IPatronageService, + IReadyExecutor, + IExecPreCommand, + IEService +{ + public event Func OnNewPatronPayment = static delegate { return Task.CompletedTask; }; + public event Func OnPatronUpdated = static delegate { return Task.CompletedTask; }; + public event Func OnPatronRefunded = static delegate { return Task.CompletedTask; }; + + // this has to run right before the command + public int Priority + => int.MinValue; + + private static readonly PatronTier[] _tiers = Enum.GetValues(); + + private readonly PatronageConfig _pConf; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly ISubscriptionHandler _subsHandler; + + private static readonly TypedKey _quotaKey + = new($"quota:last_hourly_reset"); + + private readonly IBotCache _cache; + private readonly IBotCredsProvider _creds; + private readonly IMessageSenderService _sender; + + public PatronageService( + PatronageConfig pConf, + DbService db, + DiscordSocketClient client, + ISubscriptionHandler subsHandler, + IBotCache cache, + IBotCredsProvider creds, + IMessageSenderService sender) + { + _pConf = pConf; + _db = db; + _client = client; + _subsHandler = subsHandler; + _sender = sender; + _cache = cache; + _creds = creds; + } + + public Task OnReadyAsync() + { + if (_client.ShardId != 0) + return Task.CompletedTask; + + return Task.WhenAll(ResetLoopAsync(), LoadSubscribersLoopAsync()); + } + + private async Task LoadSubscribersLoopAsync() + { + var timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); + while (await timer.WaitForNextTickAsync()) + { + try + { + if (!_pConf.Data.IsEnabled) + continue; + + await foreach (var batch in _subsHandler.GetPatronsAsync()) + { + await ProcesssPatronsAsync(batch); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error processing patrons"); + } + } + } + + public async Task ResetLoopAsync() + { + await Task.Delay(1.Minutes()); + while (true) + { + try + { + if (!_pConf.Data.IsEnabled) + { + await Task.Delay(1.Minutes()); + continue; + } + + var now = DateTime.UtcNow; + var lastRun = DateTime.MinValue; + + var result = await _cache.GetAsync(_quotaKey); + if (result.TryGetValue(out var lastVal) && lastVal != default) + { + lastRun = DateTime.FromBinary(lastVal); + } + + var nowDate = now.ToDateOnly(); + var lastDate = lastRun.ToDateOnly(); + + await using var ctx = _db.GetDbContext(); + + if ((lastDate.Day == 1 || (lastDate.Month != nowDate.Month)) && nowDate.Day > 1) + { + // assumes bot won't be offline for a year + await ctx.GetTable() + .TruncateAsync(); + } + else if (nowDate.DayNumber != lastDate.DayNumber) + { + // day is different, means hour is different. + // reset both hourly and daily quota counts. + await ctx.GetTable() + .UpdateAsync((old) => new() + { + HourlyCount = 0, + DailyCount = 0, + }); + } + else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas + { + await ctx.GetTable() + .UpdateAsync((old) => new() + { + HourlyCount = 0 + }); + } + + // assumes that the code above runs in less than an hour + await _cache.AddAsync(_quotaKey, now.ToBinary()); + } + catch (Exception ex) + { + Log.Error(ex, "Error in quota reset loop. Message: {ErrorMessage}", ex.Message); + } + + await Task.Delay(TimeSpan.FromHours(1).Add(TimeSpan.FromMinutes(1))); + } + } + + private async Task ProcesssPatronsAsync(IReadOnlyCollection subscribersEnum) + { + // process only users who have discord accounts connected + var subscribers = subscribersEnum.Where(x => x.UserId != 0).ToArray(); + + if (subscribers.Length == 0) + return; + + var todayDate = DateTime.UtcNow.Date; + await using var ctx = _db.GetDbContext(); + + // handle paid users + foreach (var subscriber in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Paid)) + { + if (subscriber.LastCharge is null) + continue; + + var lastChargeUtc = subscriber.LastCharge.Value.ToUniversalTime(); + var dateInOneMonth = lastChargeUtc.Date.AddMonths(1); + try + { + var dbPatron = await ctx.GetTable() + .FirstOrDefaultAsync(x + => x.UniquePlatformUserId == subscriber.UniquePlatformUserId); + + if (dbPatron is null) + { + // if the user is not in the database alrady + dbPatron = await ctx.GetTable() + .InsertWithOutputAsync(() => new() + { + UniquePlatformUserId = subscriber.UniquePlatformUserId, + UserId = subscriber.UserId, + AmountCents = subscriber.Cents, + LastCharge = lastChargeUtc, + ValidThru = dateInOneMonth, + }); + + // await tran.CommitAsync(); + + var newPatron = PatronUserToPatron(dbPatron); + _ = SendWelcomeMessage(newPatron); + await OnNewPatronPayment(newPatron); + } + else + { + if (dbPatron.LastCharge.Month < lastChargeUtc.Month + || dbPatron.LastCharge.Year < lastChargeUtc.Year) + { + // user is charged again for this month + // if his sub would end in teh future, extend it by one month. + // if it's not, just add 1 month to the last charge date + var count = await ctx.GetTable() + .Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId) + .UpdateAsync(old => new() + { + UserId = subscriber.UserId, + AmountCents = subscriber.Cents, + LastCharge = lastChargeUtc, + ValidThru = old.ValidThru >= todayDate + // ? Sql.DateAdd(Sql.DateParts.Month, 1, old.ValidThru).Value + ? old.ValidThru.AddMonths(1) + : dateInOneMonth, + }); + + // this should never happen + if (count == 0) + { + // await tran.RollbackAsync(); + continue; + } + + // await tran.CommitAsync(); + + await OnNewPatronPayment(PatronUserToPatron(dbPatron)); + } + else if (dbPatron.AmountCents != subscriber.Cents // if user changed the amount + || dbPatron.UserId != subscriber.UserId) // if user updated user id) + { + var cents = subscriber.Cents; + // the user updated the pledge or changed the connected discord account + await ctx.GetTable() + .Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId) + .UpdateAsync(old => new() + { + UserId = subscriber.UserId, + AmountCents = cents, + LastCharge = lastChargeUtc, + ValidThru = old.ValidThru, + }); + + var newPatron = dbPatron.Clone(); + newPatron.AmountCents = cents; + newPatron.UserId = subscriber.UserId; + + // idk what's going on but UpdateWithOutputAsync doesn't work properly here + // nor does firstordefault after update. I'm not seeing something obvious + await OnPatronUpdated( + PatronUserToPatron(dbPatron), + PatronUserToPatron(newPatron)); + } + } + } + catch (Exception ex) + { + Log.Error(ex, + "Unexpected error occured while processing rewards for patron {UserId}", + subscriber.UserId); + } + } + + var expiredDate = DateTime.MinValue; + foreach (var patron in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Refunded)) + { + // if the subscription is refunded, Disable user's valid thru + var changedCount = await ctx.GetTable() + .Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId + && x.ValidThru != expiredDate) + .UpdateAsync(old => new() + { + ValidThru = expiredDate + }); + + if (changedCount == 0) + continue; + + var updated = await ctx.GetTable() + .Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId) + .FirstAsync(); + + await OnPatronRefunded(PatronUserToPatron(updated)); + } + } + + public async Task ExecPreCommandAsync( + ICommandContext ctx, + string moduleName, + CommandInfo command) + { + var ownerId = ctx.Guild?.OwnerId ?? 0; + + var result = await AttemptRunCommand( + ctx.User.Id, + ownerId: ownerId, + command.Aliases.First().ToLowerInvariant(), + command.Module.Parent == null ? string.Empty : command.Module.GetGroupName().ToLowerInvariant(), + moduleName.ToLowerInvariant() + ); + + return result.Match( + _ => false, + ins => + { + var eb = _sender.CreateEmbed() + .WithPendingColor() + .WithTitle("Insufficient Patron Tier") + .AddField("For", $"{ins.FeatureType}: `{ins.Feature}`", true) + .AddField("Required Tier", + $"[{ins.RequiredTier.ToFullName()}](https://patreon.com/join/elliebot)", + true); + + if (ctx.Guild is null || ctx.Guild?.OwnerId == ctx.User.Id) + eb.WithDescription("You don't have the sufficent Patron Tier to run this command.") + .WithFooter("You can use '.patron' and '.donate' commands for more info"); + else + eb.WithDescription( + "Neither you nor the server owner have the sufficent Patron Tier to run this command.") + .WithFooter("You can use '.patron' and '.donate' commands for more info"); + + _ = ctx.WarningAsync(); + + if (ctx.Guild?.OwnerId == ctx.User.Id) + _ = _sender.Response(ctx) + .Context(ctx) + .Embed(eb) + .SendAsync(); + else + _ = _sender.Response(ctx).User(ctx.User).Embed(eb).SendAsync(); + + return true; + }, + quota => + { + var eb = _sender.CreateEmbed() + .WithPendingColor() + .WithTitle("Quota Limit Reached"); + + if (quota.IsOwnQuota || ctx.User.Id == ownerId) + { + eb.WithDescription($"You've reached your quota of `{quota.Quota} {quota.QuotaPeriod.ToFullName()}`") + .WithFooter("You may want to check your quota by using the '.patron' command."); + } + else + { + eb.WithDescription( + $"This server reached the quota of {quota.Quota} `{quota.QuotaPeriod.ToFullName()}`") + .WithFooter("You may contact the server owner about this issue.\n" + + "Alternatively, you can become patron yourself by using the '.donate' command.\n" + + "If you're already a patron, it means you've reached your quota.\n" + + "You can use '.patron' command to check your quota status."); + } + + eb.AddField("For", $"{quota.FeatureType}: `{quota.Feature}`", true) + .AddField("Resets At", quota.ResetsAt.ToShortAndRelativeTimestampTag(), true); + + _ = ctx.WarningAsync(); + + // send the message in the server in case it's the owner + if (ctx.Guild?.OwnerId == ctx.User.Id) + _ = _sender.Response(ctx) + .Embed(eb) + .SendAsync(); + else + _ = _sender.Response(ctx).User(ctx.User).Embed(eb).SendAsync(); + + return true; + }); + } + + private async ValueTask> AttemptRunCommand( + ulong userId, + ulong ownerId, + string commandName, + string groupName, + string moduleName) + { + // try to run as a user + var res = await AttemptRunCommand(userId, commandName, groupName, moduleName, true); + + // if it fails, try to run as an owner + // but only if the command is ran in a server + // and if the owner is not the user + if (!res.IsT0 && ownerId != 0 && ownerId != userId) + res = await AttemptRunCommand(ownerId, commandName, groupName, moduleName, false); + + return res; + } + + /// + /// Returns either the current usage counter if limit wasn't reached, or QuotaLimit if it is. + /// + public async ValueTask> TryIncrementQuotaCounterAsync( + ulong userId, + bool isSelf, + FeatureType featureType, + string featureName, + uint? maybeHourly, + uint? maybeDaily, + uint? maybeMonthly) + { + await using var ctx = _db.GetDbContext(); + + var now = DateTime.UtcNow; + await using var tran = await ctx.Database.BeginTransactionAsync(); + + var userQuotaData = await ctx.GetTable() + .FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId + && x.Feature == featureName) + ?? new PatronQuota(); + + // if hourly exists, if daily exists, etc... + if (maybeHourly is uint hourly && userQuotaData.HourlyCount >= hourly) + { + return new QuotaLimit() + { + QuotaPeriod = QuotaPer.PerHour, + Quota = hourly, + // quite a neat trick. https://stackoverflow.com/a/5733560 + ResetsAt = now.Date.AddHours(now.Hour + 1), + Feature = featureName, + FeatureType = featureType, + IsOwnQuota = isSelf + }; + } + + if (maybeDaily is uint daily + && userQuotaData.DailyCount >= daily) + { + return new QuotaLimit() + { + QuotaPeriod = QuotaPer.PerDay, + Quota = daily, + ResetsAt = now.Date.AddDays(1), + Feature = featureName, + FeatureType = featureType, + IsOwnQuota = isSelf + }; + } + + if (maybeMonthly is uint monthly && userQuotaData.MonthlyCount >= monthly) + { + return new QuotaLimit() + { + QuotaPeriod = QuotaPer.PerMonth, + Quota = monthly, + ResetsAt = now.Date.SecondOfNextMonth(), + Feature = featureName, + FeatureType = featureType, + IsOwnQuota = isSelf + }; + } + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + FeatureType = featureType, + Feature = featureName, + DailyCount = 1, + MonthlyCount = 1, + HourlyCount = 1, + }, + (old) => new() + { + HourlyCount = old.HourlyCount + 1, + DailyCount = old.DailyCount + 1, + MonthlyCount = old.MonthlyCount + 1, + }, + () => new() + { + UserId = userId, + FeatureType = featureType, + Feature = featureName, + }); + + await tran.CommitAsync(); + + return (userQuotaData.HourlyCount + 1, userQuotaData.DailyCount + 1, userQuotaData.MonthlyCount + 1); + } + + /// + /// Attempts to add 1 to user's quota for the command, group and module. + /// Input MUST BE lowercase + /// + /// Id of the user who is attempting to run the command + /// Name of the command the user is trying to run + /// Name of the command's group + /// Name of the command's top level module + /// Whether this is check is for the user himself. False if it's someone else's id (owner) + /// Either a succcess (user can run the command) or one of the error values. + private async ValueTask> AttemptRunCommand( + ulong userId, + string commandName, + string groupName, + string moduleName, + bool isSelf) + { + var confData = _pConf.Data; + + if (!confData.IsEnabled) + return default; + + if (_creds.GetCreds().IsOwner(userId)) + return default; + + // get user tier + var patron = await GetPatronAsync(userId); + FeatureType quotaForFeatureType; + + if (confData.Quotas.Commands.TryGetValue(commandName, out var quotaData)) + { + quotaForFeatureType = FeatureType.Command; + } + else if (confData.Quotas.Groups.TryGetValue(groupName, out quotaData)) + { + quotaForFeatureType = FeatureType.Group; + } + else if (confData.Quotas.Modules.TryGetValue(moduleName, out quotaData)) + { + quotaForFeatureType = FeatureType.Module; + } + else + { + return default; + } + + var featureName = quotaForFeatureType switch + { + FeatureType.Command => commandName, + FeatureType.Group => groupName, + FeatureType.Module => moduleName, + _ => throw new ArgumentOutOfRangeException(nameof(quotaForFeatureType)) + }; + + if (!TryGetTierDataOrLower(quotaData, patron.Tier, out var data)) + { + return new InsufficientTier() + { + Feature = featureName, + FeatureType = quotaForFeatureType, + RequiredTier = quotaData.Count == 0 + ? PatronTier.ComingSoon + : quotaData.Keys.First(), + UserTier = patron.Tier, + }; + } + + // no quota limits for this tier + if (data is null) + return default; + + var quotaCheckResult = await TryIncrementQuotaCounterAsync(userId, + isSelf, + quotaForFeatureType, + featureName, + data.TryGetValue(QuotaPer.PerHour, out var hourly) ? hourly : null, + data.TryGetValue(QuotaPer.PerDay, out var daily) ? daily : null, + data.TryGetValue(QuotaPer.PerMonth, out var monthly) ? monthly : null + ); + + return quotaCheckResult.Match>( + _ => new Success(), + x => x); + } + + private bool TryGetTierDataOrLower( + IReadOnlyDictionary data, + PatronTier tier, + out T? o) + { + // check for quotas on this tier + if (data.TryGetValue(tier, out o)) + return true; + + // if there are none, get the quota first tier below this one + // which has quotas specified + for (var i = _tiers.Length - 1; i >= 0; i--) + { + var lowerTier = _tiers[i]; + if (lowerTier < tier && data.TryGetValue(lowerTier, out o)) + return true; + } + + // if there are none, that means the feature is intended + // to be patron-only but the quotas haven't been specified yet + // so it will be marked as "Coming Soon" + o = default; + return false; + } + + public async Task GetPatronAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + + // this can potentially return multiple users if the user + // is subscribed on multiple platforms + // or if there are multiple users on the same platform who connected the same discord account?! + var users = await ctx.GetTable() + .Where(x => x.UserId == userId) + .ToListAsync(); + + // first find all active subscriptions + // and return the one with the highest amount + var maxActive = users.Where(x => !x.ValidThru.IsBeforeToday()).MaxBy(x => x.AmountCents); + if (maxActive is not null) + return PatronUserToPatron(maxActive); + + // if there are no active subs, return the one with the highest amount + + var max = users.MaxBy(x => x.AmountCents); + if (max is null) + return default; // no patron with that name + + return PatronUserToPatron(max); + } + + public async Task GetUserQuotaStatistic(ulong userId) + { + var pConfData = _pConf.Data; + + if (!pConfData.IsEnabled) + return new(); + + var patron = await GetPatronAsync(userId); + + await using var ctx = _db.GetDbContext(); + var allPatronQuotas = await ctx.GetTable() + .Where(x => x.UserId == userId) + .ToListAsync(); + + var allQuotasDict = allPatronQuotas + .GroupBy(static x => x.FeatureType) + .ToDictionary(static x => x.Key, static x => x.ToDictionary(static y => y.Feature)); + + allQuotasDict.TryGetValue(FeatureType.Command, out var data); + var userCommandQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Commands); + + allQuotasDict.TryGetValue(FeatureType.Group, out data); + var userGroupQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Groups); + + allQuotasDict.TryGetValue(FeatureType.Module, out data); + var userModuleQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Modules); + + return new UserQuotaStats() + { + Tier = patron.Tier, + Commands = userCommandQuotaStats, + Groups = userGroupQuotaStats, + Modules = userModuleQuotaStats, + }; + } + + private IReadOnlyDictionary GetFeatureQuotaStats( + PatronTier patronTier, + IReadOnlyDictionary? allQuotasDict, + Dictionary?>> commands) + { + var userCommandQuotaStats = new Dictionary(); + foreach (var (key, quotaData) in commands) + { + if (TryGetTierDataOrLower(quotaData, patronTier, out var data)) + { + // if data is null that means the quota for the user's tier is unlimited + // no point in returning it? + + if (data is null) + continue; + + var (daily, hourly, monthly) = default((uint, uint, uint)); + // try to get users stats for this feature + // if it fails just leave them at 0 + if (allQuotasDict?.TryGetValue(key, out var quota) ?? false) + (daily, hourly, monthly) = (quota.DailyCount, quota.HourlyCount, quota.MonthlyCount); + + userCommandQuotaStats[key] = new FeatureQuotaStats() + { + Hourly = data.TryGetValue(QuotaPer.PerHour, out var hourD) + ? (hourly, hourD) + : default, + Daily = data.TryGetValue(QuotaPer.PerDay, out var maxD) + ? (daily, maxD) + : default, + Monthly = data.TryGetValue(QuotaPer.PerMonth, out var maxM) + ? (monthly, maxM) + : default, + }; + } + } + + return userCommandQuotaStats; + } + + public async Task TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue) + { + var conf = _pConf.Data; + + // if patron system is disabled, the quota is just default + if (!conf.IsEnabled) + return new() + { + Name = key.PrettyName, + Quota = defaultValue, + IsPatronLimit = false + }; + + + if (!conf.Quotas.Features.TryGetValue(key.Key, out var data)) + return new() + { + Name = key.PrettyName, + Quota = defaultValue, + IsPatronLimit = false, + }; + + var patron = await GetPatronAsync(userId); + if (!TryGetTierDataOrLower(data, patron.Tier, out var limit)) + return new() + { + Name = key.PrettyName, + Quota = 0, + IsPatronLimit = true, + }; + + return new() + { + Name = key.PrettyName, + Quota = limit, + IsPatronLimit = true + }; + } + + // public async Task GiftPatronAsync(IUser user, int amount) + // { + // if (amount < 1) + // throw new ArgumentOutOfRangeException(nameof(amount)); + // + // + // } + + private Patron PatronUserToPatron(PatronUser user) + => new Patron() + { + UniquePlatformUserId = user.UniquePlatformUserId, + UserId = user.UserId, + Amount = user.AmountCents, + Tier = CalculateTier(user), + PaidAt = user.LastCharge, + ValidThru = user.ValidThru, + }; + + private PatronTier CalculateTier(PatronUser user) + { + if (user.ValidThru.IsBeforeToday()) + return PatronTier.None; + + return user.AmountCents switch + { + >= 10_000 => PatronTier.C, + >= 5000 => PatronTier.L, + >= 2000 => PatronTier.XX, + >= 1000 => PatronTier.X, + >= 500 => PatronTier.V, + >= 100 => PatronTier.I, + _ => PatronTier.None + }; + } + + private async Task SendWelcomeMessage(Patron patron) + { + try + { + var user = (IUser)_client.GetUser(patron.UserId) ?? await _client.Rest.GetUserAsync(patron.UserId); + if (user is null) + return; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("❤️ Thank you for supporting EllieBot! ❤️") + .WithDescription( + "Your donation has been processed and you will receive the rewards shortly.\n" + + "You can visit to see rewards for your tier. 🎉") + .AddField("Tier", Format.Bold(patron.Tier.ToString()), true) + .AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true) + .AddField("Expires", + patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), + true) + .AddField("Instructions", + """ + *- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.* + *- You can check your benefits on * + *- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands* + *- **ALL** of the servers that you **own** will enjoy your Patron benefits.* + *- You can use any of the commands available in your tier on any server (assuming you have sufficient permissions to run those commands)* + *- Any user in any of your servers can use Patron-only commands, but they will spend **your quota**, which is why it's recommended to use Ellie's command cooldown system (.h .cmdcd) or permission system to limit the command usage for your server members.* + *- Permission guide can be found here if you're not familiar with it: * + """, + inline: false) + .WithFooter($"platform id: {patron.UniquePlatformUserId}"); + + await _sender.Response(user).Embed(eb).SendAsync(); + } + catch + { + Log.Warning("Unable to send a \"Welcome\" message to the patron {UserId}", patron.UserId); + } + } + + public async Task<(int Success, int Failed)> SendMessageToPatronsAsync(PatronTier tierAndHigher, string message) + { + await using var ctx = _db.GetDbContext(); + + var patrons = await ctx.GetTable() + .Where(x => x.ValidThru > DateTime.UtcNow) + .ToArrayAsync(); + + var text = SmartText.CreateFrom(message); + + var succ = 0; + var fail = 0; + foreach (var patron in patrons) + { + try + { + var user = await _client.GetUserAsync(patron.UserId); + await _sender.Response(user).Text(text).SendAsync(); + ++succ; + } + catch + { + ++fail; + } + + await Task.Delay(1000); + } + + return (succ, fail); + } + + public PatronConfigData GetConfig() + => _pConf.Data; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs b/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs new file mode 100644 index 0000000..9db46fb --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs @@ -0,0 +1,154 @@ +#nullable disable +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class BlacklistCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public BlacklistCommands(DiscordSocketClient client) + => _client = client; + + private async Task ListBlacklistInternal(string title, BlacklistType type, int page = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(page); + + var list = _service.GetBlacklist(); + var allItems = await list.Where(x => x.Type == type) + .Select(i => + { + try + { + return Task.FromResult(i.Type switch + { + BlacklistType.Channel => Format.Code(i.ItemId.ToString()) + + " " + + (_client.GetChannel(i.ItemId)?.ToString() + ?? ""), + BlacklistType.User => Format.Code(i.ItemId.ToString()) + + " " + + ((_client.GetUser(i.ItemId)) + ?.ToString() + ?? ""), + BlacklistType.Server => Format.Code(i.ItemId.ToString()) + + " " + + (_client.GetGuild(i.ItemId)?.ToString() ?? ""), + _ => Format.Code(i.ItemId.ToString()) + }); + } + catch + { + Log.Warning("Can't get {BlacklistType} [{BlacklistItemId}]", + i.Type, + i.ItemId); + + return Task.FromResult(Format.Code(i.ItemId.ToString())); + } + }) + .WhenAll(); + + await Response() + .Paginated() + .Items(allItems) + .PageSize(10) + .CurrentPage(page) + .Page((pageItems, _) => + { + if (pageItems.Count == 0) + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(title) + .WithDescription(GetText(strs.empty_page)); + + return _sender.CreateEmbed() + .WithTitle(title) + .WithDescription(allItems.Join('\n')) + .WithOkColor(); + }) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_users), BlacklistType.User, page); + } + + [Cmd] + [OwnerOnly] + public Task ChannelBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_channels), BlacklistType.Channel, page); + } + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_servers), BlacklistType.Server, page); + } + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.User); + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(AddRemove action, IUser usr) + => Blacklist(action, usr.Id, BlacklistType.User); + + [Cmd] + [OwnerOnly] + public Task ChannelBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.Channel); + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.Server); + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(AddRemove action, IGuild guild) + => Blacklist(action, guild.Id, BlacklistType.Server); + + private async Task Blacklist(AddRemove action, ulong id, BlacklistType type) + { + if (action == AddRemove.Add) + await _service.Blacklist(type, id); + else + await _service.UnBlacklist(type, id); + + if (action == AddRemove.Add) + { + await Response() + .Confirm(strs.blacklisted(Format.Code(type.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.unblacklisted(Format.Code(type.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs new file mode 100644 index 0000000..618ec59 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs @@ -0,0 +1,15 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using static EllieBot.Common.TypeReaders.TypeReaderResult; + +namespace EllieBot.Modules.Permissions; + +public class CleverbotResponseCmdCdTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync( + ICommandContext ctx, + string input) + => input.ToLowerInvariant() == CleverBotResponseStr.CLEVERBOT_RESPONSE + ? new(FromSuccess(new CleverBotResponseStr())) + : new(FromError(CommandError.ParseFailed, "Not a valid cleverbot")); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs new file mode 100644 index 0000000..f4ea9e8 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs @@ -0,0 +1,142 @@ +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; + +namespace EllieBot.Modules.Permissions.Services; + +public sealed class CmdCdService : IExecPreCommand, IReadyExecutor, IEService +{ + private readonly DbService _db; + private readonly ConcurrentDictionary> _settings = new(); + + private readonly ConcurrentDictionary<(ulong, string), ConcurrentDictionary> _activeCooldowns = + new(); + + public int Priority => 0; + + public CmdCdService(IBot bot, DbService db) + { + _db = db; + _settings = bot + .AllGuildConfigs + .ToDictionary(x => x.GuildId, x => x.CommandCooldowns + .DistinctBy(x => x.CommandName.ToLowerInvariant()) + .ToDictionary(c => c.CommandName, c => c.Seconds) + .ToConcurrent()) + .ToConcurrent(); + } + + public Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + => TryBlock(context.Guild, context.User, command.Name.ToLowerInvariant()); + + public Task TryBlock(IGuild? guild, IUser user, string commandName) + { + if (guild is null) + return Task.FromResult(false); + + if (!_settings.TryGetValue(guild.Id, out var cooldownSettings)) + return Task.FromResult(false); + + if (!cooldownSettings.TryGetValue(commandName, out var cdSeconds)) + return Task.FromResult(false); + + var cooldowns = _activeCooldowns.GetOrAdd( + (guild.Id, commandName), + static _ => new()); + + // if user is not already on cooldown, add + if (cooldowns.TryAdd(user.Id, DateTime.UtcNow)) + { + return Task.FromResult(false); + } + + // if there is an entry, maybe it expired. Try to check if it expired and don't fail if it did + // - just update + if (cooldowns.TryGetValue(user.Id, out var oldValue)) + { + var diff = DateTime.UtcNow - oldValue; + if (diff.TotalSeconds > cdSeconds) + { + if (cooldowns.TryUpdate(user.Id, DateTime.UtcNow, oldValue)) + return Task.FromResult(false); + } + } + + return Task.FromResult(true); + } + + public async Task OnReadyAsync() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + + while (await timer.WaitForNextTickAsync()) + { + // once per hour delete expired entries + foreach (var ((guildId, commandName), dict) in _activeCooldowns) + { + // if this pair no longer has associated config, that means it has been removed. + // remove all cooldowns + if (!_settings.TryGetValue(guildId, out var inner) + || !inner.TryGetValue(commandName, out var cdSeconds)) + { + _activeCooldowns.Remove((guildId, commandName), out _); + continue; + } + + Cleanup(dict, cdSeconds); + } + } + } + + private void Cleanup(ConcurrentDictionary dict, int cdSeconds) + { + var now = DateTime.UtcNow; + foreach (var (key, _) in dict.Where(x => (now - x.Value).TotalSeconds > cdSeconds).ToArray()) + { + dict.TryRemove(key, out _); + } + } + + public void ClearCooldowns(ulong guildId, string cmdName) + { + if (_settings.TryGetValue(guildId, out var dict)) + dict.TryRemove(cmdName, out _); + + _activeCooldowns.TryRemove((guildId, cmdName), out _); + + using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, x => x.Include(x => x.CommandCooldowns)); + gc.CommandCooldowns.RemoveWhere(x => x.CommandName == cmdName); + ctx.SaveChanges(); + } + + public void AddCooldown(ulong guildId, string name, int secs) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(secs); + + var sett = _settings.GetOrAdd(guildId, static _ => new()); + sett[name] = secs; + + // force cleanup + if (_activeCooldowns.TryGetValue((guildId, name), out var dict)) + Cleanup(dict, secs); + + using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, x => x.Include(x => x.CommandCooldowns)); + gc.CommandCooldowns.RemoveWhere(x => x.CommandName == name); + gc.CommandCooldowns.Add(new() + { + Seconds = secs, + CommandName = name + }); + ctx.SaveChanges(); + } + + public IReadOnlyCollection<(string CommandName, int Seconds)> GetCommandCooldowns(ulong guildId) + { + if (!_settings.TryGetValue(guildId, out var dict)) + return Array.Empty<(string, int)>(); + + return dict.Select(x => (x.Key, x.Value)).ToArray(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs new file mode 100644 index 0000000..e2c1427 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs @@ -0,0 +1,107 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.TypeReaders; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class CmdCdsCommands : EllieModule + { + private readonly DbService _db; + private readonly CmdCdService _service; + + public CmdCdsCommands(CmdCdService service, DbService db) + { + _service = service; + _db = db; + } + + private async Task CmdCooldownInternal(string cmdName, int secs) + { + var channel = (ITextChannel)ctx.Channel; + if (secs is < 0 or > 3600) + { + await Response().Error(strs.invalid_second_param_between(0, 3600)).SendAsync(); + return; + } + + var name = cmdName.ToLowerInvariant(); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.CommandCooldowns)); + + var toDelete = config.CommandCooldowns.FirstOrDefault(cc => cc.CommandName == name); + if (toDelete is not null) + uow.Set().Remove(toDelete); + if (secs != 0) + { + var cc = new CommandCooldown + { + CommandName = name, + Seconds = secs + }; + config.CommandCooldowns.Add(cc); + _service.AddCooldown(channel.Guild.Id, name, secs); + } + + await uow.SaveChangesAsync(); + } + + if (secs == 0) + { + _service.ClearCooldowns(ctx.Guild.Id, cmdName); + await Response().Confirm(strs.cmdcd_cleared(Format.Bold(name))).SendAsync(); + } + else + await Response().Confirm(strs.cmdcd_add(Format.Bold(name), Format.Bold(secs.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task CmdCooldown(CleverBotResponseStr command, int secs) + => CmdCooldownInternal(CleverBotResponseStr.CLEVERBOT_RESPONSE, secs); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task CmdCooldown(CommandOrExprInfo command, int secs) + => CmdCooldownInternal(command.Name, secs); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllCmdCooldowns(int page = 1) + { + if (--page < 0) + return; + + var localSet = _service.GetCommandCooldowns(ctx.Guild.Id); + + if (!localSet.Any()) + await Response().Confirm(strs.cmdcd_none).SendAsync(); + else + { + await Response() + .Paginated() + .Items(localSet) + .PageSize(15) + .CurrentPage(page) + .Page((items, _) => + { + var output = items.Select(x => + $"{Format.Code(x.CommandName)}: {x.Seconds}s"); + + return _sender.CreateEmbed() + .WithOkColor() + .WithDescription(output.Join("\n")); + }) + .SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs b/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs new file mode 100644 index 0000000..cdd3cad --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs @@ -0,0 +1,327 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class FilterCommands : EllieModule + { + private readonly DbService _db; + + public FilterCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task FwClear() + { + _service.ClearFilteredWords(ctx.Guild.Id); + await Response().Confirm(strs.fw_cleared).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task FilterList() + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("Server filter settings"); + + var config = await _service.GetFilterSettings(ctx.Guild.Id); + + string GetEnabledEmoji(bool value) + => value ? "\\🟢" : "\\🔴"; + + async Task GetChannelListAsync(IReadOnlyCollection channels) + { + var toReturn = (await channels + .Select(async cid => + { + var ch = await ctx.Guild.GetChannelAsync(cid); + return ch is null + ? $"{cid} *missing*" + : $"<#{cid}>"; + }) + .WhenAll()) + .Join('\n'); + + if (string.IsNullOrWhiteSpace(toReturn)) + return GetText(strs.no_channel_found); + + return toReturn; + } + + embed.AddField($"{GetEnabledEmoji(config.FilterLinksEnabled)} Filter Links", + await GetChannelListAsync(config.FilterLinksChannels)); + + embed.AddField($"{GetEnabledEmoji(config.FilterInvitesEnabled)} Filter Invites", + await GetChannelListAsync(config.FilterInvitesChannels)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterInv() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterInvites = !config.FilterInvites; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.InviteFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.invite_filter_server_on).SendAsync(); + } + else + { + _service.InviteFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.invite_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterInv() + { + var channel = (ITextChannel)ctx.Channel; + + FilterChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, + set => set.Include(gc => gc.FilterInvitesChannelIds)); + var match = new FilterChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterInvitesChannelIds.FirstOrDefault(fc => fc.Equals(match)); + + if (removed is null) + config.FilterInvitesChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.InviteFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.invite_filter_channel_on).SendAsync(); + } + else + { + _service.InviteFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.invite_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterLin() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterLinks = !config.FilterLinks; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.LinkFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.link_filter_server_on).SendAsync(); + } + else + { + _service.LinkFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.link_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterLin() + { + var channel = (ITextChannel)ctx.Channel; + + FilterLinksChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = + uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilterLinksChannelIds)); + var match = new FilterLinksChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterLinksChannelIds.FirstOrDefault(fc => fc.Equals(match)); + + if (removed is null) + config.FilterLinksChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.LinkFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.link_filter_channel_on).SendAsync(); + } + else + { + _service.LinkFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.link_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterWords() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterWords = !config.FilterWords; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.WordFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.word_filter_server_on).SendAsync(); + } + else + { + _service.WordFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.word_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterWords() + { + var channel = (ITextChannel)ctx.Channel; + + FilterWordsChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = + uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilterWordsChannelIds)); + + var match = new FilterWordsChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterWordsChannelIds.FirstOrDefault(fc => fc.Equals(match)); + if (removed is null) + config.FilterWordsChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.WordFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.word_filter_channel_on).SendAsync(); + } + else + { + _service.WordFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.word_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task FilterWord([Leftover] string word) + { + var channel = (ITextChannel)ctx.Channel; + + word = word?.Trim().ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(word)) + return; + + FilteredWord removed; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilteredWords)); + + removed = config.FilteredWords.FirstOrDefault(fw => fw.Word.Trim().ToLowerInvariant() == word); + + if (removed is null) + { + config.FilteredWords.Add(new() + { + Word = word + }); + } + else + uow.Remove(removed); + + await uow.SaveChangesAsync(); + } + + var filteredWords = + _service.ServerFilteredWords.GetOrAdd(channel.Guild.Id, new ConcurrentHashSet()); + + if (removed is null) + { + filteredWords.Add(word); + await Response().Confirm(strs.filter_word_add(Format.Code(word))).SendAsync(); + } + else + { + filteredWords.TryRemove(word); + await Response().Confirm(strs.filter_word_remove(Format.Code(word))).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task LstFilterWords(int page = 1) + { + page--; + if (page < 0) + return; + + var channel = (ITextChannel)ctx.Channel; + + _service.ServerFilteredWords.TryGetValue(channel.Guild.Id, out var fwHash); + + var fws = fwHash.ToArray(); + + await Response() + .Paginated() + .Items(fws) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => _sender.CreateEmbed() + .WithTitle(GetText(strs.filter_word_list)) + .WithDescription(string.Join("\n", items)) + .WithOkColor()) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/FilterService.cs b/src/EllieBot/Modules/Permissions/Filter/FilterService.cs new file mode 100644 index 0000000..9dfe01e --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/FilterService.cs @@ -0,0 +1,250 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Services; + +public sealed class FilterService : IExecOnMessage +{ + public ConcurrentHashSet InviteFilteringChannels { get; } + public ConcurrentHashSet InviteFilteringServers { get; } + + //serverid, filteredwords + public ConcurrentDictionary> ServerFilteredWords { get; } + + public ConcurrentHashSet WordFilteringChannels { get; } + public ConcurrentHashSet WordFilteringServers { get; } + + public ConcurrentHashSet LinkFilteringChannels { get; } + public ConcurrentHashSet LinkFilteringServers { get; } + + public int Priority + => int.MaxValue - 1; + + private readonly DbService _db; + + public FilterService(DiscordSocketClient client, DbService db) + { + _db = db; + + using (var uow = db.GetDbContext()) + { + var ids = client.GetGuildIds(); + var configs = uow.Set() + .AsQueryable() + .Include(x => x.FilteredWords) + .Include(x => x.FilterLinksChannelIds) + .Include(x => x.FilterWordsChannelIds) + .Include(x => x.FilterInvitesChannelIds) + .Where(gc => ids.Contains(gc.GuildId)) + .ToList(); + + InviteFilteringServers = new(configs.Where(gc => gc.FilterInvites).Select(gc => gc.GuildId)); + InviteFilteringChannels = + new(configs.SelectMany(gc => gc.FilterInvitesChannelIds.Select(fci => fci.ChannelId))); + + LinkFilteringServers = new(configs.Where(gc => gc.FilterLinks).Select(gc => gc.GuildId)); + LinkFilteringChannels = + new(configs.SelectMany(gc => gc.FilterLinksChannelIds.Select(fci => fci.ChannelId))); + + var dict = configs.ToDictionary(gc => gc.GuildId, + gc => new ConcurrentHashSet(gc.FilteredWords.Select(fw => fw.Word).Distinct())); + + ServerFilteredWords = new(dict); + + var serverFiltering = configs.Where(gc => gc.FilterWords); + WordFilteringServers = new(serverFiltering.Select(gc => gc.GuildId)); + WordFilteringChannels = + new(configs.SelectMany(gc => gc.FilterWordsChannelIds.Select(fwci => fwci.ChannelId))); + } + + client.MessageUpdated += (oldData, newMsg, channel) => + { + _ = Task.Run(() => + { + var guild = (channel as ITextChannel)?.Guild; + + if (guild is null || newMsg is not IUserMessage usrMsg) + return Task.CompletedTask; + + return ExecOnMessageAsync(guild, usrMsg); + }); + return Task.CompletedTask; + }; + } + + public ConcurrentHashSet FilteredWordsForChannel(ulong channelId, ulong guildId) + { + var words = new ConcurrentHashSet(); + if (WordFilteringChannels.Contains(channelId)) + ServerFilteredWords.TryGetValue(guildId, out words); + return words; + } + + public void ClearFilteredWords(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.FilteredWords).Include(x => x.FilterWordsChannelIds)); + + WordFilteringServers.TryRemove(guildId); + ServerFilteredWords.TryRemove(guildId, out _); + + foreach (var c in gc.FilterWordsChannelIds) + WordFilteringChannels.TryRemove(c.ChannelId); + + gc.FilterWords = false; + gc.FilteredWords.Clear(); + gc.FilterWordsChannelIds.Clear(); + + uow.SaveChanges(); + } + + public ConcurrentHashSet FilteredWordsForServer(ulong guildId) + { + var words = new ConcurrentHashSet(); + if (WordFilteringServers.Contains(guildId)) + ServerFilteredWords.TryGetValue(guildId, out words); + return words; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + if (msg.Author is not IGuildUser gu || gu.GuildPermissions.Administrator) + return false; + + var results = await Task.WhenAll(FilterInvites(guild, msg), FilterWords(guild, msg), FilterLinks(guild, msg)); + + return results.Any(x => x); + } + + private async Task FilterWords(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + var filteredChannelWords = + FilteredWordsForChannel(usrMsg.Channel.Id, guild.Id) ?? new ConcurrentHashSet(); + var filteredServerWords = FilteredWordsForServer(guild.Id) ?? new ConcurrentHashSet(); + var wordsInMessage = usrMsg.Content.ToLowerInvariant().Split(' '); + if (filteredChannelWords.Count != 0 || filteredServerWords.Count != 0) + { + foreach (var word in wordsInMessage) + { + if (filteredChannelWords.Contains(word) || filteredServerWords.Contains(word)) + { + Log.Information("User {UserName} [{UserId}] used a filtered word in {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + } + catch (HttpException ex) + { + Log.Warning(ex, + "I do not have permission to filter words in channel with id {Id}", + usrMsg.Channel.Id); + } + + return true; + } + } + } + + return false; + } + + private async Task FilterInvites(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + // if user has manage messages perm, don't filter + if (usrMsg.Channel is ITextChannel ch && usrMsg.Author is IGuildUser gu && gu.GetPermissions(ch).ManageMessages) + return false; + + if ((InviteFilteringChannels.Contains(usrMsg.Channel.Id) || InviteFilteringServers.Contains(guild.Id)) + && usrMsg.Content.IsDiscordInvite()) + { + Log.Information("User {UserName} [{UserId}] sent a filtered invite to {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + return true; + } + catch (HttpException ex) + { + Log.Warning(ex, + "I do not have permission to filter invites in channel with id {Id}", + usrMsg.Channel.Id); + return true; + } + } + + return false; + } + + private async Task FilterLinks(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + // if user has manage messages perm, don't filter + if (usrMsg.Channel is ITextChannel ch && usrMsg.Author is IGuildUser gu && gu.GetPermissions(ch).ManageMessages) + return false; + + if ((LinkFilteringChannels.Contains(usrMsg.Channel.Id) || LinkFilteringServers.Contains(guild.Id)) + && usrMsg.Content.TryGetUrlPath(out _)) + { + Log.Information("User {UserName} [{UserId}] sent a filtered link to {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + return true; + } + catch (HttpException ex) + { + Log.Warning(ex, "I do not have permission to filter links in channel with id {Id}", usrMsg.Channel.Id); + return true; + } + } + + return false; + } + + public async Task GetFilterSettings(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set + .Include(x => x.FilterInvitesChannelIds) + .Include(x => x.FilterLinksChannelIds)); + + return new() + { + FilterInvitesChannels = gc.FilterInvitesChannelIds.Map(x => x.ChannelId), + FilterLinksChannels = gc.FilterLinksChannelIds.Map(x => x.ChannelId), + FilterInvitesEnabled = gc.FilterInvites, + FilterLinksEnabled = gc.FilterLinks, + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs b/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs new file mode 100644 index 0000000..bf8454b --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Permissions.Services; + +public readonly struct ServerFilterSettings +{ + public bool FilterInvitesEnabled { get; init; } + public bool FilterLinksEnabled { get; init; } + public IReadOnlyCollection FilterInvitesChannels { get; init; } + public IReadOnlyCollection FilterLinksChannels { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs new file mode 100644 index 0000000..d3abefd --- /dev/null +++ b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs @@ -0,0 +1,77 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Permissions.Services; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class GlobalPermissionCommands : EllieModule + { + private readonly GlobalPermissionService _service; + private readonly DbService _db; + + public GlobalPermissionCommands(GlobalPermissionService service, DbService db) + { + _service = service; + _db = db; + } + + [Cmd] + [OwnerOnly] + public async Task GlobalPermList() + { + var blockedModule = _service.BlockedModules; + var blockedCommands = _service.BlockedCommands; + if (!blockedModule.Any() && !blockedCommands.Any()) + { + await Response().Error(strs.lgp_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor(); + + if (blockedModule.Any()) + embed.AddField(GetText(strs.blocked_modules), string.Join("\n", _service.BlockedModules)); + + if (blockedCommands.Any()) + embed.AddField(GetText(strs.blocked_commands), string.Join("\n", _service.BlockedCommands)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GlobalModule(ModuleOrExpr module) + { + var moduleName = module.Name.ToLowerInvariant(); + + var added = _service.ToggleModule(moduleName); + + if (added) + { + await Response().Confirm(strs.gmod_add(Format.Bold(module.Name))).SendAsync(); + return; + } + + await Response().Confirm(strs.gmod_remove(Format.Bold(module.Name))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GlobalCommand(CommandOrExprInfo cmd) + { + var commandName = cmd.Name.ToLowerInvariant(); + var added = _service.ToggleCommand(commandName); + + if (added) + { + await Response().Confirm(strs.gcmd_add(Format.Bold(cmd.Name))).SendAsync(); + return; + } + + await Response().Confirm(strs.gcmd_remove(Format.Bold(cmd.Name))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs new file mode 100644 index 0000000..00dfd78 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs @@ -0,0 +1,92 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Permissions.Services; + +public class GlobalPermissionService : IExecPreCommand, IEService +{ + public int Priority { get; } = 0; + + public HashSet BlockedCommands + => _bss.Data.Blocked.Commands; + + public HashSet BlockedModules + => _bss.Data.Blocked.Modules; + + private readonly BotConfigService _bss; + + public GlobalPermissionService(BotConfigService bss) + => _bss = bss; + + + public Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) + { + var settings = _bss.Data; + var commandName = command.Name.ToLowerInvariant(); + + if (commandName != "resetglobalperms" + && (settings.Blocked.Commands.Contains(commandName) + || settings.Blocked.Modules.Contains(moduleName.ToLowerInvariant()))) + return Task.FromResult(true); + + return Task.FromResult(false); + } + + /// + /// Toggles module blacklist + /// + /// Lowercase module name + /// Whether the module is added + public bool ToggleModule(string moduleName) + { + var added = false; + _bss.ModifyConfig(bs => + { + if (bs.Blocked.Modules.Add(moduleName)) + added = true; + else + { + bs.Blocked.Modules.Remove(moduleName); + added = false; + } + }); + + return added; + } + + /// + /// Toggles command blacklist + /// + /// Lowercase command name + /// Whether the command is added + public bool ToggleCommand(string commandName) + { + var added = false; + _bss.ModifyConfig(bs => + { + if (bs.Blocked.Commands.Add(commandName)) + added = true; + else + { + bs.Blocked.Commands.Remove(commandName); + added = false; + } + }); + + return added; + } + + /// + /// Resets all global permissions + /// + public Task Reset() + { + _bss.ModifyConfig(bs => + { + bs.Blocked.Commands.Clear(); + bs.Blocked.Modules.Clear(); + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionCache.cs b/src/EllieBot/Modules/Permissions/PermissionCache.cs new file mode 100644 index 0000000..47b5983 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionCache.cs @@ -0,0 +1,11 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Common; + +public class PermissionCache +{ + public string PermRole { get; set; } + public bool Verbose { get; set; } = true; + public PermissionsCollection Permissions { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionExtensions.cs b/src/EllieBot/Modules/Permissions/PermissionExtensions.cs new file mode 100644 index 0000000..04eee4e --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionExtensions.cs @@ -0,0 +1,132 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Common; + +public static class PermissionExtensions +{ + public static bool CheckPermissions( + this IEnumerable permsEnumerable, + IUser user, + IMessageChannel message, + string commandName, + string moduleName, + out int permIndex) + { + var perms = permsEnumerable as List ?? permsEnumerable.ToList(); + + for (var i = perms.Count - 1; i >= 0; i--) + { + var perm = perms[i]; + + var result = perm.CheckPermission(user, message, commandName, moduleName); + + if (result is null) + continue; + permIndex = i; + return result.Value; + } + + permIndex = -1; //defaut behaviour + return true; + } + + //null = not applicable + //true = applicable, allowed + //false = applicable, not allowed + public static bool? CheckPermission( + this Permissionv2 perm, + IUser user, + IMessageChannel channel, + string commandName, + string moduleName) + { + if (!((perm.SecondaryTarget == SecondaryPermissionType.Command + && string.Equals(perm.SecondaryTargetName, commandName, StringComparison.InvariantCultureIgnoreCase)) + || (perm.SecondaryTarget == SecondaryPermissionType.Module + && string.Equals(perm.SecondaryTargetName, moduleName, StringComparison.InvariantCultureIgnoreCase)) + || perm.SecondaryTarget == SecondaryPermissionType.AllModules)) + return null; + + var guildUser = user as IGuildUser; + + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + if (perm.PrimaryTargetId == user.Id) + return perm.State; + break; + case PrimaryPermissionType.Channel: + if (perm.PrimaryTargetId == channel.Id) + return perm.State; + break; + case PrimaryPermissionType.Role: + if (guildUser is null) + break; + if (guildUser.RoleIds.Contains(perm.PrimaryTargetId)) + return perm.State; + break; + case PrimaryPermissionType.Server: + if (guildUser is null) + break; + return perm.State; + } + + return null; + } + + public static string GetCommand(this Permissionv2 perm, string prefix, SocketGuild guild = null) + { + var com = string.Empty; + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + com += "u"; + break; + case PrimaryPermissionType.Channel: + com += "c"; + break; + case PrimaryPermissionType.Role: + com += "r"; + break; + case PrimaryPermissionType.Server: + com += "s"; + break; + } + + switch (perm.SecondaryTarget) + { + case SecondaryPermissionType.Module: + com += "m"; + break; + case SecondaryPermissionType.Command: + com += "c"; + break; + case SecondaryPermissionType.AllModules: + com = "a" + com + "m"; + break; + } + + var secName = perm.SecondaryTarget == SecondaryPermissionType.Command && !perm.IsCustomCommand + ? prefix + perm.SecondaryTargetName + : perm.SecondaryTargetName; + com += " " + (perm.SecondaryTargetName != "*" ? secName + " " : "") + (perm.State ? "enable" : "disable") + " "; + + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + com += guild?.GetUser(perm.PrimaryTargetId)?.ToString() ?? $"<@{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Channel: + com += $"<#{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Role: + com += guild?.GetRole(perm.PrimaryTargetId)?.ToString() ?? $"<@&{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Server: + break; + } + + return prefix + com; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Permissions.cs b/src/EllieBot/Modules/Permissions/Permissions.cs new file mode 100644 index 0000000..90de56b --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Permissions.cs @@ -0,0 +1,544 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Common; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions : EllieModule +{ + public enum Reset { Reset } + + private readonly DbService _db; + + public Permissions(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Verbose(PermissionAction action = null) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + if (action is null) + action = new(!config.VerbosePermissions); // New behaviour, can toggle. + config.VerbosePermissions = action.Value; + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + if (action.Value) + await Response().Confirm(strs.verbose_true).SendAsync(); + else + await Response().Confirm(strs.verbose_false).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public async Task PermRole([Leftover] IRole role = null) + { + if (role is not null && role == role.Guild.EveryoneRole) + return; + + if (role is null) + { + var cache = _service.GetCacheFor(ctx.Guild.Id); + if (!ulong.TryParse(cache.PermRole, out var roleId) + || (role = ((SocketGuild)ctx.Guild).GetRole(roleId)) is null) + await Response().Confirm(strs.permrole_not_set).SendAsync(); + else + await Response().Confirm(strs.permrole(Format.Bold(role.ToString()))).SendAsync(); + return; + } + + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + config.PermissionRole = role.Id.ToString(); + uow.SaveChanges(); + _service.UpdateCache(config); + } + + await Response().Confirm(strs.permrole_changed(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public async Task PermRole(Reset _) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + config.PermissionRole = null; + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response().Confirm(strs.permrole_reset).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ListPerms(int page = 1) + { + if (page < 1) + return; + + IList perms; + + if (_service.Cache.TryGetValue(ctx.Guild.Id, out var permCache)) + perms = permCache.Permissions.Source.ToList(); + else + perms = Permissionv2.GetDefaultPermlist; + + var startPos = 20 * (page - 1); + var toSend = Format.Bold(GetText(strs.page(page))) + + "\n\n" + + string.Join("\n", + perms.Reverse() + .Skip(startPos) + .Take(20) + .Select(p => + { + var str = + $"`{p.Index + 1}.` {Format.Bold(p.GetCommand(prefix, (SocketGuild)ctx.Guild))}"; + if (p.Index == 0) + str += $" [{GetText(strs.uneditable)}]"; + return str; + })); + + await Response().Confirm(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RemovePerm(int index) + { + index -= 1; + if (index < 0) + return; + try + { + Permissionv2 p; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + var permsCol = new PermissionsCollection(config.Permissions); + p = permsCol[index]; + permsCol.RemoveAt(index); + uow.Remove(p); + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response() + .Confirm(strs.removed(index + 1, + Format.Code(p.GetCommand(prefix, (SocketGuild)ctx.Guild)))) + .SendAsync(); + } + catch (IndexOutOfRangeException) + { + await Response().Error(strs.perm_out_of_range).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task MovePerm(int from, int to) + { + from -= 1; + to -= 1; + if (!(from == to || from < 0 || to < 0)) + { + try + { + Permissionv2 fromPerm; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + var permsCol = new PermissionsCollection(config.Permissions); + + var fromFound = from < permsCol.Count; + var toFound = to < permsCol.Count; + + if (!fromFound) + { + await Response().Error(strs.perm_not_found(++from)).SendAsync(); + return; + } + + if (!toFound) + { + await Response().Error(strs.perm_not_found(++to)).SendAsync(); + return; + } + + fromPerm = permsCol[from]; + + permsCol.RemoveAt(from); + permsCol.Insert(to, fromPerm); + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response() + .Confirm(strs.moved_permission( + Format.Code(fromPerm.GetCommand(prefix, (SocketGuild)ctx.Guild)), + ++from, + ++to)) + .SendAsync(); + + return; + } + catch (Exception e) when (e is ArgumentOutOfRangeException or IndexOutOfRangeException) + { + } + } + + await Response().Confirm(strs.perm_out_of_range).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrCmd(CommandOrExprInfo command, PermissionAction action) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + await Response().Confirm(strs.sx_enable(Format.Code(command.Name), GetText(strs.of_command))).SendAsync(); + else + await Response().Confirm(strs.sx_disable(Format.Code(command.Name), GetText(strs.of_command))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrMdl(ModuleOrExpr module, PermissionAction action) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.sx_enable(Format.Code(module.Name), GetText(strs.of_module))).SendAsync(); + else + await Response().Confirm(strs.sx_disable(Format.Code(module.Name), GetText(strs.of_module))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UsrCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] IGuildUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.ux_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(user.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.ux_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(user.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UsrMdl(ModuleOrExpr module, PermissionAction action, [Leftover] IGuildUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + { + await Response() + .Confirm(strs.ux_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(user.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.ux_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(user.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RoleCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.rx_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(role.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.rx_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(role.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RoleMdl(ModuleOrExpr module, PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + + if (action.Value) + { + await Response() + .Confirm(strs.rx_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(role.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.rx_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(role.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.cx_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(chnl.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.cx_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(chnl.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlMdl(ModuleOrExpr module, PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + { + await Response() + .Confirm(strs.cx_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(chnl.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.cx_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(chnl.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllChnlMdls(PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.acm_enable(Format.Code(chnl.Name))).SendAsync(); + else + await Response().Confirm(strs.acm_disable(Format.Code(chnl.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllRoleMdls(PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.arm_enable(Format.Code(role.Name))).SendAsync(); + else + await Response().Confirm(strs.arm_disable(Format.Code(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllUsrMdls(PermissionAction action, [Leftover] IUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.aum_enable(Format.Code(user.ToString()))).SendAsync(); + else + await Response().Confirm(strs.aum_disable(Format.Code(user.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllSrvrMdls(PermissionAction action) + { + var newPerm = new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }; + + var allowUser = new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = ctx.User.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = true + }; + + await _service.AddPermissions(ctx.Guild.Id, newPerm, allowUser); + + if (action.Value) + await Response().Confirm(strs.asm_enable).SendAsync(); + else + await Response().Confirm(strs.asm_disable).SendAsync(); + } +} diff --git a/src/EllieBot/Modules/Permissions/PermissionsCollection.cs b/src/EllieBot/Modules/Permissions/PermissionsCollection.cs new file mode 100644 index 0000000..c2526ea --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionsCollection.cs @@ -0,0 +1,74 @@ +#nullable disable +namespace EllieBot.Modules.Permissions.Common; + +public class PermissionsCollection : IndexedCollection + where T : class, IIndexed +{ + public override T this[int index] + { + get => Source[index]; + set + { + lock (_localLocker) + { + if (index == 0) // can't set first element. It's always allow all + throw new IndexOutOfRangeException(nameof(index)); + base[index] = value; + } + } + } + + private readonly object _localLocker = new(); + + public PermissionsCollection(IEnumerable source) + : base(source) + { + } + + public static implicit operator List(PermissionsCollection x) + => x.Source; + + public override void Clear() + { + lock (_localLocker) + { + var first = Source[0]; + base.Clear(); + Source[0] = first; + } + } + + public override bool Remove(T item) + { + bool removed; + lock (_localLocker) + { + if (Source.IndexOf(item) == 0) + throw new ArgumentException("You can't remove first permsission (allow all)"); + removed = base.Remove(item); + } + + return removed; + } + + public override void Insert(int index, T item) + { + lock (_localLocker) + { + if (index == 0) // can't insert on first place. Last item is always allow all. + throw new IndexOutOfRangeException(nameof(index)); + base.Insert(index, item); + } + } + + public override void RemoveAt(int index) + { + lock (_localLocker) + { + if (index == 0) // you can't remove first permission (allow all) + throw new IndexOutOfRangeException(nameof(index)); + + base.RemoveAt(index); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionsService.cs b/src/EllieBot/Modules/Permissions/PermissionsService.cs new file mode 100644 index 0000000..7bca184 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionsService.cs @@ -0,0 +1,187 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Common; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Services; + +public class PermissionService : IExecPreCommand, IEService +{ + public int Priority { get; } = 0; + + //guildid, root permission + public ConcurrentDictionary Cache { get; } = new(); + + private readonly DbService _db; + private readonly CommandHandler _cmd; + private readonly IBotStrings _strings; + private readonly IMessageSenderService _sender; + + public PermissionService( + DiscordSocketClient client, + DbService db, + CommandHandler cmd, + IBotStrings strings, + IMessageSenderService sender) + { + _db = db; + _cmd = cmd; + _strings = strings; + _sender = sender; + + using var uow = _db.GetDbContext(); + foreach (var x in uow.Set().PermissionsForAll(client.Guilds.ToArray().Select(x => x.Id).ToList())) + { + Cache.TryAdd(x.GuildId, + new() + { + Verbose = x.VerbosePermissions, + PermRole = x.PermissionRole, + Permissions = new(x.Permissions) + }); + } + } + + public PermissionCache GetCacheFor(ulong guildId) + { + if (!Cache.TryGetValue(guildId, out var pc)) + { + using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.Permissions)); + UpdateCache(config); + } + + Cache.TryGetValue(guildId, out pc); + if (pc is null) + throw new("Cache is null."); + } + + return pc; + } + + public async Task AddPermissions(ulong guildId, params Permissionv2[] perms) + { + await using var uow = _db.GetDbContext(); + var config = uow.GcWithPermissionsFor(guildId); + //var orderedPerms = new PermissionsCollection(config.Permissions); + var max = config.Permissions.Max(x => x.Index); //have to set its index to be the highest + foreach (var perm in perms) + { + perm.Index = ++max; + config.Permissions.Add(perm); + } + + await uow.SaveChangesAsync(); + UpdateCache(config); + } + + public void UpdateCache(GuildConfig config) + => Cache.AddOrUpdate(config.GuildId, + new PermissionCache + { + Permissions = new(config.Permissions), + PermRole = config.PermissionRole, + Verbose = config.VerbosePermissions + }, + (_, old) => + { + old.Permissions = new(config.Permissions); + old.PermRole = config.PermissionRole; + old.Verbose = config.VerbosePermissions; + return old; + }); + + public async Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) + { + var guild = ctx.Guild; + var msg = ctx.Message; + var user = ctx.User; + var channel = ctx.Channel; + var commandName = command.Name.ToLowerInvariant(); + + if (guild is null) + return false; + + var resetCommand = commandName == "resetperms"; + + var pc = GetCacheFor(guild.Id); + if (!resetCommand + && !pc.Permissions.CheckPermissions(msg.Author, msg.Channel, commandName, moduleName, out var index)) + { + if (pc.Verbose) + { + try + { + await _sender.Response(channel) + .Error(_strings.GetText(strs.perm_prevent(index + 1, + Format.Bold(pc.Permissions[index] + .GetCommand(_cmd.GetPrefix(guild), (SocketGuild)guild))), + guild.Id)) + .SendAsync(); + } + catch + { + } + } + + return true; + } + + + if (moduleName == nameof(Permissions)) + { + if (user is not IGuildUser guildUser) + return true; + + if (guildUser.GuildPermissions.Administrator) + return false; + + var permRole = pc.PermRole; + if (!ulong.TryParse(permRole, out var rid)) + rid = 0; + string returnMsg; + IRole role; + if (string.IsNullOrWhiteSpace(permRole) || (role = guild.GetRole(rid)) is null) + { + returnMsg = "You need Admin permissions in order to use permission commands."; + if (pc.Verbose) + { + try + { await _sender.Response(channel).Error(returnMsg).SendAsync(); } + catch { } + } + + return true; + } + + if (!guildUser.RoleIds.Contains(rid)) + { + returnMsg = $"You need the {Format.Bold(role.Name)} role in order to use permission commands."; + if (pc.Verbose) + { + try + { await _sender.Response(channel).Error(returnMsg).SendAsync(); } + catch { } + } + + return true; + } + + return false; + } + + return false; + } + + public async Task Reset(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var config = uow.GcWithPermissionsFor(guildId); + config.Permissions = Permissionv2.GetDefaultPermlist; + await uow.SaveChangesAsync(); + UpdateCache(config); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs b/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs new file mode 100644 index 0000000..4193337 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs @@ -0,0 +1,37 @@ +#nullable disable +using EllieBot.Modules.Permissions.Services; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class ResetPermissionsCommands : EllieModule + { + private readonly GlobalPermissionService _gps; + private readonly PermissionService _perms; + + public ResetPermissionsCommands(GlobalPermissionService gps, PermissionService perms) + { + _gps = gps; + _perms = perms; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ResetPerms() + { + await _perms.Reset(ctx.Guild.Id); + await Response().Confirm(strs.perms_reset).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ResetGlobalPerms() + { + await _gps.Reset(); + await Response().Confirm(strs.global_perms_reset).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs b/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs new file mode 100644 index 0000000..c47eed7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs @@ -0,0 +1,41 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class AnimeResult +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("airing_status")] + public string AiringStatusParsed { get; set; } + + [JsonPropertyName("title_english")] + public string TitleEnglish { get; set; } + + [JsonPropertyName("total_episodes")] + public int TotalEpisodes { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("image_url_lge")] + public string ImageUrlLarge { get; set; } + + [JsonPropertyName("genres")] + public string[] Genres { get; set; } + + [JsonPropertyName("average_score")] + public float AverageScore { get; set; } + + + public string AiringStatus + => AiringStatusParsed.ToTitleCase(); + + public string Link + => "http://anilist.co/anime/" + Id; + + public string Synopsis + => Description?[..(Description.Length > 500 ? 500 : Description.Length)] + "..."; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs b/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs new file mode 100644 index 0000000..8acae72 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs @@ -0,0 +1,204 @@ +#nullable disable +using AngleSharp; +using AngleSharp.Html.Dom; +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class AnimeSearchCommands : EllieModule + { + // [EllieCommand, Aliases] + // public async Task Novel([Leftover] string query) + // { + // if (string.IsNullOrWhiteSpace(query)) + // return; + // + // var novelData = await _service.GetNovelData(query); + // + // if (novelData is null) + // { + // await Response().Error(strs.failed_finding_novel).SendAsync(); + // return; + // } + // + // var embed = _sender.CreateEmbed() + // .WithOkColor() + // .WithDescription(novelData.Description.Replace("
", Environment.NewLine, StringComparison.InvariantCulture)) + // .WithTitle(novelData.Title) + // .WithUrl(novelData.Link) + // .WithImageUrl(novelData.ImageUrl) + // .AddField(GetText(strs.authors), string.Join("\n", novelData.Authors), true) + // .AddField(GetText(strs.status), novelData.Status, true) + // .AddField(GetText(strs.genres), string.Join(" ", novelData.Genres.Any() ? novelData.Genres : new[] { "none" }), true) + // .WithFooter($"{GetText(strs.score)} {novelData.Score}"); + // + // await Response().Embed(embed).SendAsync(); + // } + + [Cmd] + [Priority(0)] + public async Task Mal([Leftover] string name) + { + if (string.IsNullOrWhiteSpace(name)) + return; + + var fullQueryLink = "https://myanimelist.net/profile/" + name; + + var config = Configuration.Default.WithDefaultLoader(); + using var document = await BrowsingContext.New(config).OpenAsync(fullQueryLink); + var imageElem = + document.QuerySelector( + "body > div#myanimelist > div.wrapper > div#contentWrapper > div#content > div.content-container > div.container-left > div.user-profile > div.user-image > img"); + var imageUrl = ((IHtmlImageElement)imageElem)?.Source + ?? "http://icecream.me/uploads/870b03f36b59cc16ebfe314ef2dde781.png"; + + var stats = document + .QuerySelectorAll( + "body > div#myanimelist > div.wrapper > div#contentWrapper > div#content > div.content-container > div.container-right > div#statistics > div.user-statistics-stats > div.stats > div.clearfix > ul.stats-status > li > span") + .Select(x => x.InnerHtml) + .ToList(); + + var favorites = document.QuerySelectorAll("div.user-favorites > div.di-tc"); + + var favAnime = GetText(strs.anime_no_fav); + if (favorites.Length > 0 && favorites[0].QuerySelector("p") is null) + { + favAnime = string.Join("\n", + favorites[0] + .QuerySelectorAll("ul > li > div.di-tc.va-t > a") + .Shuffle() + .Take(3) + .Select(x => + { + var elem = (IHtmlAnchorElement)x; + return $"[{elem.InnerHtml}]({elem.Href})"; + })); + } + + var info = document.QuerySelectorAll("ul.user-status:nth-child(3) > li.clearfix") + .Select(x => Tuple.Create(x.Children[0].InnerHtml, x.Children[1].InnerHtml)) + .ToList(); + + var daysAndMean = document.QuerySelectorAll("div.anime:nth-child(1) > div:nth-child(2) > div") + .Select(x => x.TextContent.Split(':').Select(y => y.Trim()).ToArray()) + .ToArray(); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.mal_profile(name))) + .AddField("💚 " + GetText(strs.watching), stats[0], true) + .AddField("💙 " + GetText(strs.completed), stats[1], true); + if (info.Count < 3) + embed.AddField("💛 " + GetText(strs.on_hold), stats[2], true); + embed.AddField("💔 " + GetText(strs.dropped), stats[3], true) + .AddField("⚪ " + GetText(strs.plan_to_watch), stats[4], true) + .AddField("🕐 " + daysAndMean[0][0], daysAndMean[0][1], true) + .AddField("📊 " + daysAndMean[1][0], daysAndMean[1][1], true) + .AddField(MalInfoToEmoji(info[0].Item1) + " " + info[0].Item1, info[0].Item2.TrimTo(20), true) + .AddField(MalInfoToEmoji(info[1].Item1) + " " + info[1].Item1, info[1].Item2.TrimTo(20), true); + if (info.Count > 2) + embed.AddField(MalInfoToEmoji(info[2].Item1) + " " + info[2].Item1, info[2].Item2.TrimTo(20), true); + + embed.WithDescription($@" +** https://myanimelist.net/animelist/{name} ** + +**{GetText(strs.top_3_fav_anime)}** +{favAnime}") + .WithUrl(fullQueryLink) + .WithImageUrl(imageUrl); + + await Response().Embed(embed).SendAsync(); + } + + private static string MalInfoToEmoji(string info) + { + info = info.Trim().ToLowerInvariant(); + switch (info) + { + case "gender": + return "🚁"; + case "location": + return "🗺"; + case "last online": + return "👥"; + case "birthday": + return "📆"; + default: + return "❔"; + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task Mal(IGuildUser usr) + => Mal(usr.Username); + + [Cmd] + public async Task Anime([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + var animeData = await _service.GetAnimeData(query); + + if (animeData is null) + { + await Response().Error(strs.failed_finding_anime).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(animeData.Synopsis.Replace("
", + Environment.NewLine, + StringComparison.InvariantCulture)) + .WithTitle(animeData.TitleEnglish) + .WithUrl(animeData.Link) + .WithImageUrl(animeData.ImageUrlLarge) + .AddField(GetText(strs.episodes), animeData.TotalEpisodes.ToString(), true) + .AddField(GetText(strs.status), animeData.AiringStatus, true) + .AddField(GetText(strs.genres), + string.Join(",\n", animeData.Genres.Any() ? animeData.Genres : ["none"]), + true) + .WithFooter($"{GetText(strs.score)} {animeData.AverageScore} / 100"); + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Manga([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + var mangaData = await _service.GetMangaData(query); + + if (mangaData is null) + { + await Response().Error(strs.failed_finding_manga).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(mangaData.Synopsis.Replace("
", + Environment.NewLine, + StringComparison.InvariantCulture)) + .WithTitle(mangaData.TitleEnglish) + .WithUrl(mangaData.Link) + .WithImageUrl(mangaData.ImageUrlLge) + .AddField(GetText(strs.chapters), mangaData.TotalChapters.ToString(), true) + .AddField(GetText(strs.status), mangaData.PublishingStatus, true) + .AddField(GetText(strs.genres), + string.Join(",\n", mangaData.Genres.Any() ? mangaData.Genres : ["none"]), + true) + .WithFooter($"{GetText(strs.score)} {mangaData.AverageScore} / 100"); + + await Response().Embed(embed).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs b/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs new file mode 100644 index 0000000..4cf1b01 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs @@ -0,0 +1,79 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using System.Net.Http.Json; + +namespace EllieBot.Modules.Searches.Services; + +public class AnimeSearchService : IEService +{ + private readonly IBotCache _cache; + private readonly IHttpClientFactory _httpFactory; + + public AnimeSearchService(IBotCache cache, IHttpClientFactory httpFactory) + { + _cache = cache; + _httpFactory = httpFactory; + } + + public async Task GetAnimeData(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + TypedKey GetKey(string link) + => new TypedKey($"anime2:{link}"); + + try + { + var suffix = Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture)); + var link = $"https://aniapi.nadeko.bot/anime/{suffix}"; + link = link.ToLowerInvariant(); + var result = await _cache.GetAsync(GetKey(link)); + if (!result.TryPickT0(out var data, out _)) + { + using var http = _httpFactory.CreateClient(); + data = await http.GetFromJsonAsync(link); + + await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(12)); + } + + return data; + } + catch + { + return null; + } + } + + public async Task GetMangaData(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + TypedKey GetKey(string link) + => new TypedKey($"manga2:{link}"); + + try + { + var link = "https://aniapi.nadeko.bot/manga/" + + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture)); + link = link.ToLowerInvariant(); + + var result = await _cache.GetAsync(GetKey(link)); + if (!result.TryPickT0(out var data, out _)) + { + using var http = _httpFactory.CreateClient(); + data = await http.GetFromJsonAsync(link); + + await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(3)); + } + + + return data; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/MangaResult.cs b/src/EllieBot/Modules/Searches/Anime/MangaResult.cs new file mode 100644 index 0000000..9a32703 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/MangaResult.cs @@ -0,0 +1,40 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class MangaResult +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("publishing_status")] + public string PublishingStatus { get; set; } + + [JsonPropertyName("image_url_lge")] + public string ImageUrlLge { get; set; } + + [JsonPropertyName("title_english")] + public string TitleEnglish { get; set; } + + [JsonPropertyName("total_chapters")] + public int TotalChapters { get; set; } + + [JsonPropertyName("total_volumes")] + public int TotalVolumes { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("genres")] + public string[] Genres { get; set; } + + [JsonPropertyName("average_score")] + public float AverageScore { get; set; } + + public string Link + => "http://anilist.co/manga/" + Id; + + public string Synopsis + => Description?[..(Description.Length > 500 ? 500 : Description.Length)] + "..."; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs b/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs new file mode 100644 index 0000000..37353b1 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs @@ -0,0 +1,196 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; +using System.Globalization; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + public partial class FinanceCommands : EllieModule + { + private readonly IStockDataService _stocksService; + private readonly IStockChartDrawingService _stockDrawingService; + + public FinanceCommands(IStockDataService stocksService, IStockChartDrawingService stockDrawingService) + { + _stocksService = stocksService; + _stockDrawingService = stockDrawingService; + } + + [Cmd] + public async Task Stock([Leftover]string query) + { + using var typing = ctx.Channel.EnterTypingState(); + + var stock = await _stocksService.GetStockDataAsync(query); + + if (stock is null) + { + var symbols = await _stocksService.SearchSymbolAsync(query); + + if (symbols.Count == 0) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + var symbol = symbols.First(); + var promptEmbed = _sender.CreateEmbed() + .WithDescription(symbol.Description) + .WithTitle(GetText(strs.did_you_mean(symbol.Symbol))); + + if (!await PromptUserConfirmAsync(promptEmbed)) + return; + + query = symbol.Symbol; + stock = await _stocksService.GetStockDataAsync(query); + + if (stock is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + } + + var candles = await _stocksService.GetCandleDataAsync(query); + var stockImageTask = _stockDrawingService.GenerateCombinedChartAsync(candles); + + var localCulture = (CultureInfo)Culture.Clone(); + localCulture.NumberFormat.CurrencySymbol = "$"; + + var sign = stock.Price >= stock.Close + ? "\\🔼" + : "\\🔻"; + + var change = (stock.Price - stock.Close).ToString("N2", Culture); + var changePercent = (1 - (stock.Close / stock.Price)).ToString("P1", Culture); + + var sign50 = stock.Change50d >= 0 + ? "\\🔼" + : "\\🔻"; + + var change50 = (stock.Change50d).ToString("P1", Culture); + + var sign200 = stock.Change200d >= 0 + ? "\\🔼" + : "\\🔻"; + + var change200 = (stock.Change200d).ToString("P1", Culture); + + var price = stock.Price.ToString("C2", localCulture); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(stock.Symbol) + .WithUrl($"https://www.tradingview.com/chart/?symbol={stock.Symbol}") + .WithTitle(stock.Name) + .AddField(GetText(strs.price), $"{sign} **{price}**", true) + .AddField(GetText(strs.market_cap), stock.MarketCap, true) + .AddField(GetText(strs.volume_24h), stock.DailyVolume.ToString("C0", localCulture), true) + .AddField("Change", $"{change} ({changePercent})", true) + // .AddField("Change 50d", $"{sign50}{change50}", true) + // .AddField("Change 200d", $"{sign200}{change200}", true) + .WithFooter(stock.Exchange); + + var message = await Response().Embed(eb).SendAsync(); + await using var imageData = await stockImageTask; + if (imageData is null) + return; + + var fileName = $"{query}-sparkline.{imageData.Extension}"; + using var attachment = new FileAttachment( + imageData.FileData, + fileName + ); + await message.ModifyAsync(mp => + { + mp.Attachments = + new(new[] + { + attachment + }); + + mp.Embed = eb.WithImageUrl($"attachment://{fileName}").Build(); + }); + } + + + [Cmd] + public async Task Crypto(string name) + { + name = name?.ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(name)) + return; + + var (crypto, nearest) = await _service.GetCryptoData(name); + + if (nearest is not null) + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.crypto_not_found)) + .WithDescription( + GetText(strs.did_you_mean(Format.Bold($"{nearest.Name} ({nearest.Symbol})")))); + + if (await PromptUserConfirmAsync(embed)) + crypto = nearest; + } + + if (crypto is null) + { + await Response().Error(strs.crypto_not_found).SendAsync(); + return; + } + + var usd = crypto.Quote["USD"]; + + var localCulture = (CultureInfo)Culture.Clone(); + localCulture.NumberFormat.CurrencySymbol = "$"; + + var sevenDay = (usd.PercentChange7d / 100).ToString("P2", localCulture); + var lastDay = (usd.PercentChange24h / 100).ToString("P2", localCulture); + var price = usd.Price < 0.01 + ? usd.Price.ToString(localCulture) + : usd.Price.ToString("C2", localCulture); + + var volume = usd.Volume24h.ToString("C0", localCulture); + var marketCap = usd.MarketCap.ToString("C0", localCulture); + var dominance = (usd.MarketCapDominance / 100).ToString("P2", localCulture); + + await using var sparkline = await _service.GetSparklineAsync(crypto.Id, usd.PercentChange7d >= 0); + var fileName = $"{crypto.Slug}_7d.png"; + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor($"#{crypto.CmcRank}") + .WithTitle($"{crypto.Name} ({crypto.Symbol})") + .WithUrl($"https://coinmarketcap.com/currencies/{crypto.Slug}/") + .WithThumbnailUrl($"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png") + .AddField(GetText(strs.market_cap), marketCap, true) + .AddField(GetText(strs.price), price, true) + .AddField(GetText(strs.volume_24h), volume, true) + .AddField(GetText(strs.change_7d_24h), $"{sevenDay} / {lastDay}", true) + .AddField(GetText(strs.market_cap_dominance), dominance, true) + .WithImageUrl($"attachment://{fileName}"); + + if (crypto.CirculatingSupply is double cs) + { + var csStr = cs.ToString("N0", localCulture); + + if (crypto.MaxSupply is double ms) + { + var perc = (cs / ms).ToString("P1", localCulture); + + toSend.AddField(GetText(strs.circulating_supply), $"{csStr} ({perc})", true); + } + else + { + toSend.AddField(GetText(strs.circulating_supply), csStr, true); + } + } + + + await ctx.Channel.SendFileAsync(sparkline, fileName, embed: toSend.Build()); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs b/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs new file mode 100644 index 0000000..146dac3 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs @@ -0,0 +1,215 @@ +#nullable enable +using EllieBot.Modules.Searches.Common; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Globalization; +using System.Net.Http.Json; +using System.Xml; +using Color = SixLabors.ImageSharp.Color; +using StringExtensions = EllieBot.Extensions.StringExtensions; + +namespace EllieBot.Modules.Searches.Services; + +public class CryptoService : IEService +{ + private readonly IBotCache _cache; + private readonly IHttpClientFactory _httpFactory; + private readonly IBotCredentials _creds; + + private readonly SemaphoreSlim _getCryptoLock = new(1, 1); + + public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds) + { + _cache = cache; + _httpFactory = httpFactory; + _creds = creds; + } + + private PointF[] GetSparklinePointsFromSvgText(string svgText) + { + var xml = new XmlDocument(); + xml.LoadXml(svgText); + + var gElement = xml["svg"]?["g"]; + if (gElement is null) + return Array.Empty(); + + Span points = new PointF[gElement.ChildNodes.Count]; + var cnt = 0; + + bool GetValuesFromAttributes( + XmlAttributeCollection attrs, + out float x1, + out float y1, + out float x2, + out float y2) + { + (x1, y1, x2, y2) = (0, 0, 0, 0); + return attrs["x1"]?.Value is string x1Str + && float.TryParse(x1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x1) + && attrs["y1"]?.Value is string y1Str + && float.TryParse(y1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y1) + && attrs["x2"]?.Value is string x2Str + && float.TryParse(x2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x2) + && attrs["y2"]?.Value is string y2Str + && float.TryParse(y2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y2); + } + + foreach (XmlElement x in gElement.ChildNodes) + { + if (x.Name != "line") + continue; + + if (GetValuesFromAttributes(x.Attributes, out var x1, out var y1, out var x2, out var y2)) + { + points[cnt++] = new(x1, y1); + // this point will be set twice to the same value + // on all points except the last one + if (cnt + 1 < points.Length) + points[cnt + 1] = new(x2, y2); + } + } + + if (cnt == 0) + return Array.Empty(); + + return points.Slice(0, cnt).ToArray(); + } + + private SixLabors.ImageSharp.Image GenerateSparklineChart(PointF[] points, bool up) + { + const int width = 164; + const int height = 48; + + var img = new Image(width, height, Color.Transparent); + var color = up + ? Color.Green + : Color.FromRgb(220, 0, 0); + + img.Mutate(x => + { + x.DrawLines(color, 2, points); + }); + + return img; + } + + public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return (null, null); + + name = name.ToUpperInvariant(); + var cryptos = await GetCryptoDataInternal(); + + if (cryptos is null or { Count: 0 }) + return (null, null); + + var crypto = cryptos.FirstOrDefault(x + => x.Slug.ToUpperInvariant() == name + || x.Name.ToUpperInvariant() == name + || x.Symbol.ToUpperInvariant() == name); + + if (crypto is not null) + return (crypto, null); + + + var nearest = cryptos + .Select(elem => (Elem: elem, + Distance: elem.Name.ToUpperInvariant().LevenshteinDistance(name))) + .OrderBy(x => x.Distance) + .FirstOrDefault(x => x.Distance <= 2); + + return (null, nearest.Elem); + } + + public async Task?> GetCryptoDataInternal() + { + await _getCryptoLock.WaitAsync(); + try + { + var data = await _cache.GetOrAddAsync(new("ellie:crypto_data"), + async () => + { + try + { + using var http = _httpFactory.CreateClient(); + var data = await http.GetFromJsonAsync( + "https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?" + + $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}" + + "&start=1" + + "&limit=5000" + + "&convert=USD"); + + return data; + } + catch (Exception ex) + { + Log.Error(ex, "Error getting crypto data: {Message}", ex.Message); + return default; + } + }, + TimeSpan.FromHours(2)); + + if (data is null) + return default; + + return data.Data; + } + catch (Exception ex) + { + Log.Error(ex, "Error retreiving crypto data: {Message}", ex.Message); + return default; + } + finally + { + _getCryptoLock.Release(); + } + } + + private TypedKey GetSparklineKey(int id) + => new($"crypto:sparkline:{id}"); + + public async Task GetSparklineAsync(int id, bool up) + { + try + { + var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id), + async () => + { + // if it fails, generate a new one + var points = await DownloadSparklinePointsAsync(id); + var sparkline = GenerateSparklineChart(points, up); + + using var stream = await sparkline.ToStreamAsync(); + return stream.ToArray(); + }, + TimeSpan.FromHours(1)); + + if (bytes is { Length: > 0 }) + { + return bytes.ToStream(); + } + + return default; + } + catch (Exception ex) + { + Log.Warning(ex, + "Exception occurred while downloading sparkline points: {ErrorMessage}", + ex.Message); + return default; + } + } + + private async Task DownloadSparklinePointsAsync(int id) + { + using var http = _httpFactory.CreateClient(); + var str = await http.GetStringAsync( + $"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg"); + var points = GetSparklinePointsFromSvgText(str); + return points; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs b/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs new file mode 100644 index 0000000..5b5bf40 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs @@ -0,0 +1,126 @@ +using AngleSharp; +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; +using System.Text.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class DefaultStockDataService : IStockDataService, IEService +{ + private readonly IHttpClientFactory _httpClientFactory; + + public DefaultStockDataService(IHttpClientFactory httpClientFactory) + => _httpClientFactory = httpClientFactory; + + public async Task GetStockDataAsync(string query) + { + try + { + if (!query.IsAlphaNumeric()) + return default; + + using var http = _httpClientFactory.CreateClient(); + + + + var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}"; + + var config = Configuration.Default.WithDefaultLoader(); + using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage); + var divElem = + document.QuerySelector( + "#quote-header-info > div:nth-child(2) > div > div > h1"); + var tickerName = (divElem)?.TextContent; + + var marketcap = document + .QuerySelectorAll("table") + .Skip(1) + .First() + .QuerySelector("tbody > tr > td:nth-child(2)") + ?.TextContent; + + + var volume = document.QuerySelector("td[data-test='AVERAGE_VOLUME_3MONTH-value']") + ?.TextContent; + + var close= document.QuerySelector("td[data-test='PREV_CLOSE-value']") + ?.TextContent ?? "0"; + + var price = document + .QuerySelector("#quote-header-info") + ?.QuerySelector("fin-streamer[data-field='regularMarketPrice']") + ?.TextContent ?? close; + + // var data = await http.GetFromJsonAsync( + // $"https://query1.finance.yahoo.com/v7/finance/quote?symbols={query}"); + // + // if (data is null) + // return default; + + // var symbol = data.QuoteResponse.Result.FirstOrDefault(); + + // if (symbol is null) + // return default; + + return new() + { + Name = tickerName, + Symbol = query, + Price = double.Parse(price, NumberStyles.Any, CultureInfo.InvariantCulture), + Close = double.Parse(close, NumberStyles.Any, CultureInfo.InvariantCulture), + MarketCap = marketcap, + DailyVolume = (long)double.Parse(volume ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture), + }; + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.ToString()); + return default; + } + } + + public async Task> SearchSymbolAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + query = Uri.EscapeDataString(query); + + using var http = _httpClientFactory.CreateClient(); + + var res = await http.GetStringAsync( + "https://finance.yahoo.com/_finance_doubledown/api/resource/searchassist" + + $";searchTerm={query}" + + "?device=console"); + + var data = JsonSerializer.Deserialize(res); + + if (data is null or { Items: null }) + return Array.Empty(); + + return data.Items + .Where(x => x.Type == "S") + .Select(x => new SymbolData(x.Symbol, x.Name)) + .ToList(); + } + + private static CsvConfiguration _csvConfig = new(CultureInfo.InvariantCulture); + + public async Task> GetCandleDataAsync(string query) + { + using var http = _httpClientFactory.CreateClient(); + await using var resStream = await http.GetStreamAsync( + $"https://query1.finance.yahoo.com/v7/finance/download/{query}" + + $"?period1={DateTime.UtcNow.Subtract(30.Days()).ToTimestamp()}" + + $"&period2={DateTime.UtcNow.ToTimestamp()}" + + "&interval=1d"); + + using var textReader = new StreamReader(resStream); + using var csv = new CsvReader(textReader, _csvConfig); + var records = csv.GetRecords().ToArray(); + + return records + .Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs new file mode 100644 index 0000000..97da2da --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs @@ -0,0 +1,12 @@ +using SixLabors.ImageSharp; + +namespace EllieBot.Modules.Searches; + +/// +/// All data required to draw a candle +/// +/// Whether the candle is green +/// Rectangle for the body +/// High line point +/// Low line point +public record CandleDrawingData(bool IsGreen, RectangleF BodyRect, PointF High, PointF Low); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs new file mode 100644 index 0000000..9676e88 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface IStockChartDrawingService +{ + Task GenerateSparklineAsync(IReadOnlyCollection series); + Task GenerateCombinedChartAsync(IReadOnlyCollection series); + Task GenerateCandleChartAsync(IReadOnlyCollection series); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs new file mode 100644 index 0000000..731fc78 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs @@ -0,0 +1,200 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Runtime.CompilerServices; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Searches; + +public sealed class ImagesharpStockChartDrawingService : IStockChartDrawingService, IEService +{ + private const int WIDTH = 300; + private const int HEIGHT = 100; + private const decimal MAX_HEIGHT = HEIGHT * 0.8m; + + private static readonly Rgba32 _backgroundColor = Rgba32.ParseHex("17181E"); + private static readonly Rgba32 _lineGuideColor = Rgba32.ParseHex("212125"); + private static readonly Rgba32 _sparklineColor = Rgba32.ParseHex("2961FC"); + private static readonly Rgba32 _greenBrush = Rgba32.ParseHex("26A69A"); + private static readonly Rgba32 _redBrush = Rgba32.ParseHex("EF5350"); + + private static float GetNormalizedPoint(decimal max, decimal point, decimal range) + => (float)((MAX_HEIGHT * ((max - point) / range)) + HeightOffset()); + + private PointF[] GetSparklinePointsInternal(IReadOnlyCollection series) + { + var candleStep = WIDTH / (series.Count + 1); + var max = series.Max(static x => x.High); + var min = series.Min(static x => x.Low); + + var range = max - min; + + var points = new PointF[series.Count]; + + var i = 0; + foreach (var candle in series) + { + var x = candleStep * (i + 1); + + var y = GetNormalizedPoint(max, candle.Close, range); + points[i++] = new(x, y); + } + + return points; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static decimal HeightOffset() + => (HEIGHT - MAX_HEIGHT) / 2m; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Image CreateCanvasInternal() + => new Image(WIDTH, HEIGHT, _backgroundColor); + + private CandleDrawingData[] GetChartDrawingDataInternal(IReadOnlyCollection series) + { + var candleMargin = 2; + var candleStep = (WIDTH - (candleMargin * series.Count)) / (series.Count + 1); + var max = series.Max(static x => x.High); + var min = series.Min(static x => x.Low); + + var range = max - min; + + var drawData = new CandleDrawingData[series.Count]; + + var candleWidth = candleStep; + + var i = 0; + foreach (var candle in series) + { + var offsetX = (i - 1) * candleMargin; + var x = (candleStep * (i + 1)) + offsetX; + var yOpen = GetNormalizedPoint(max, candle.Open, range); + var yClose = GetNormalizedPoint(max, candle.Close, range); + var y = candle.Open > candle.Close + ? yOpen + : yClose; + + var sizeH = Math.Abs(yOpen - yClose); + + var high = GetNormalizedPoint(max, candle.High, range); + var low = GetNormalizedPoint(max, candle.Low, range); + drawData[i] = new(candle.Open < candle.Close, + new(x, y, candleWidth, sizeH), + new(x + (candleStep / 2), high), + new(x + (candleStep / 2), low)); + ++i; + } + + return drawData; + } + + private void DrawChartData(Image image, CandleDrawingData[] drawData) + => image.Mutate(ctx => + { + foreach (var data in drawData) + ctx.DrawLines(data.IsGreen + ? _greenBrush + : _redBrush, + 1, + data.High, + data.Low); + + + foreach (var data in drawData) + ctx.Fill(data.IsGreen + ? _greenBrush + : _redBrush, + data.BodyRect); + }); + + private void DrawLineGuides(Image image, IReadOnlyCollection series) + { + var max = series.Max(x => x.High); + var min = series.Min(x => x.Low); + + var step = (max - min) / 5; + + var lines = new float[6]; + + for (var i = 0; i < 6; i++) + { + var y = GetNormalizedPoint(max, min + (step * i), max - min); + lines[i] = y; + } + + image.Mutate(ctx => + { + // draw guides + foreach (var y in lines) + ctx.DrawLines(_lineGuideColor, 1, new PointF(0, y), new PointF(WIDTH, y)); + + // // draw min and max price on the chart + // ctx.DrawText(min.ToString(CultureInfo.InvariantCulture), + // SystemFonts.CreateFont("Arial", 5), + // Color.White, + // new PointF(0, (float)HeightOffset() - 5) + // ); + // + // ctx.DrawText(max.ToString("N1", CultureInfo.InvariantCulture), + // SystemFonts.CreateFont("Arial", 5), + // Color.White, + // new PointF(0, HEIGHT - (float)HeightOffset()) + // ); + }); + } + + public Task GenerateSparklineAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + var points = GetSparklinePointsInternal(series); + + image.Mutate(ctx => + { + ctx.DrawLines(_sparklineColor, 2, points); + }); + + return Task.FromResult(new("png", image.ToStream())); + } + + public Task GenerateCombinedChartAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + DrawLineGuides(image, series); + + var chartData = GetChartDrawingDataInternal(series); + DrawChartData(image, chartData); + + var points = GetSparklinePointsInternal(series); + image.Mutate(ctx => + { + ctx.DrawLines(Color.ParseHex("00FFFFAA"), 1, points); + }); + + return Task.FromResult(new("png", image.ToStream())); + } + + public Task GenerateCandleChartAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + DrawLineGuides(image, series); + + var drawData = GetChartDrawingDataInternal(series); + DrawChartData(image, drawData); + + return Task.FromResult(new("png", image.ToStream())); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs b/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs new file mode 100644 index 0000000..5f778e8 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface IStockDataService +{ + public Task GetStockDataAsync(string symbol); + Task> SearchSymbolAsync(string query); + Task> GetCandleDataAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs new file mode 100644 index 0000000..97d1a17 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public record CandleData( + decimal Open, + decimal Close, + decimal High, + decimal Low, + long Volume); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs new file mode 100644 index 0000000..d49d1ba --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public record ImageData(string Extension, Stream FileData) : IAsyncDisposable +{ + public ValueTask DisposeAsync() + => FileData.DisposeAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs b/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs new file mode 100644 index 0000000..13ea277 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs @@ -0,0 +1,43 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class QuoteResponse +{ + public class ResultModel + { + [JsonPropertyName("longName")] + public string LongName { get; set; } + + [JsonPropertyName("regularMarketPrice")] + public double RegularMarketPrice { get; set; } + + [JsonPropertyName("regularMarketPreviousClose")] + public double RegularMarketPreviousClose { get; set; } + + [JsonPropertyName("fullExchangeName")] + public string FullExchangeName { get; set; } + + [JsonPropertyName("averageDailyVolume10Day")] + public int AverageDailyVolume10Day { get; set; } + + [JsonPropertyName("fiftyDayAverageChangePercent")] + public double FiftyDayAverageChangePercent { get; set; } + + [JsonPropertyName("twoHundredDayAverageChangePercent")] + public double TwoHundredDayAverageChangePercent { get; set; } + + [JsonPropertyName("marketCap")] + public long MarketCap { get; set; } + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + } + + [JsonPropertyName("result")] + public List Result { get; set; } + + [JsonPropertyName("error")] + public object Error { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs new file mode 100644 index 0000000..dfb99c7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public class StockData +{ + public string Name { get; set; } + public string Symbol { get; set; } + public double Price { get; set; } + public string MarketCap { get; set; } + public double Close { get; set; } + public double Change50d { get; set; } + public double Change200d { get; set; } + public long DailyVolume { get; set; } + public string Exchange { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs new file mode 100644 index 0000000..01ef65d --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs @@ -0,0 +1,3 @@ +namespace EllieBot.Modules.Searches; + +public record SymbolData(string Symbol, string Description); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs new file mode 100644 index 0000000..619bdc3 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Modules.Searches; + +public class YahooFinanceCandleData +{ + public DateTime Date { get; set; } + public decimal Open { get; set; } + public decimal High { get; set; } + public decimal Low { get; set; } + public decimal Close { get; set; } + public decimal AdjClose { get; set; } + public long Volume { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs new file mode 100644 index 0000000..168dd82 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooFinanceSearchResponse +{ + [JsonPropertyName("suggestionTitleAccessor")] + public string SuggestionTitleAccessor { get; set; } + + [JsonPropertyName("suggestionMeta")] + public List SuggestionMeta { get; set; } + + [JsonPropertyName("hiConf")] + public bool HiConf { get; set; } + + [JsonPropertyName("items")] + public List Items { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs new file mode 100644 index 0000000..e8eaa9f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs @@ -0,0 +1,25 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooFinanceSearchResponseItem +{ + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("exch")] + public string Exch { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("exchDisp")] + public string ExchDisp { get; set; } + + [JsonPropertyName("typeDisp")] + public string TypeDisp { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs new file mode 100644 index 0000000..4efc94f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooQueryModel +{ + [JsonPropertyName("quoteResponse")] + public QuoteResponse QuoteResponse { get; set; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs b/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs new file mode 100644 index 0000000..37376f0 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs @@ -0,0 +1,146 @@ +#nullable disable +using CodeHollow.FeedReader; +using EllieBot.Modules.Searches.Services; +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class FeedCommands : EllieModule + { + private static readonly Regex _ytChannelRegex = + new(@"youtube\.com\/(?:c\/|channel\/|user\/)?(?[a-zA-Z0-9\-_]{1,})"); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public Task YtUploadNotif(string url, [Leftover] string message = null) + => YtUploadNotif(url, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(2)] + public Task YtUploadNotif(string url, ITextChannel channel = null, [Leftover] string message = null) + { + var m = _ytChannelRegex.Match(url); + if (!m.Success) + return Response().Error(strs.invalid_input).SendAsync(); + + if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone) + message = message?.SanitizeAllMentions(); + + var channelId = m.Groups["channelid"].Value; + + return Feed($"https://www.youtube.com/feeds/videos.xml?channel_id={channelId}", channel, message); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Feed(string url, [Leftover] string message = null) + => Feed(url, null, message); + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Feed(string url, ITextChannel channel = null, [Leftover] string message = null) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + await Response().Error(strs.feed_invalid_url).SendAsync(); + return; + } + + if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone) + message = message?.SanitizeAllMentions(); + + channel ??= (ITextChannel)ctx.Channel; + try + { + await FeedReader.ReadAsync(url); + } + catch (Exception ex) + { + Log.Information(ex, "Unable to get feeds from that url"); + await Response().Error(strs.feed_cant_parse).SendAsync(); + return; + } + + if (ctx.User is not IGuildUser gu || !gu.GuildPermissions.Administrator) + message = message?.SanitizeMentions(true); + + var result = _service.AddFeed(ctx.Guild.Id, channel.Id, url, message); + if (result == FeedAddResult.Success) + { + await Response().Confirm(strs.feed_added).SendAsync(); + return; + } + + if (result == FeedAddResult.Duplicate) + { + await Response().Error(strs.feed_duplicate).SendAsync(); + return; + } + + if (result == FeedAddResult.LimitReached) + { + await Response().Error(strs.feed_limit_reached).SendAsync(); + return; + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task FeedRemove(int index) + { + if (_service.RemoveFeed(ctx.Guild.Id, --index)) + await Response().Confirm(strs.feed_removed).SendAsync(); + else + await Response().Error(strs.feed_out_of_range).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task FeedList(int page = 1) + { + if (--page < 0) + return; + + var feeds = _service.GetFeeds(ctx.Guild.Id); + + if (!feeds.Any()) + { + await Response() + .Embed(_sender.CreateEmbed().WithOkColor().WithDescription(GetText(strs.feed_no_feed))) + .SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(feeds) + .PageSize(10) + .CurrentPage(page) + .Page((items, cur) => + { + var embed = _sender.CreateEmbed().WithOkColor(); + var i = 0; + var fs = string.Join("\n", + items.Select(x => $"`{(cur * 10) + ++i}.` <#{x.ChannelId}> {x.Url}")); + + return embed.WithDescription(fs); + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs b/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs new file mode 100644 index 0000000..d19195c --- /dev/null +++ b/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs @@ -0,0 +1,294 @@ +#nullable disable +using CodeHollow.FeedReader; +using CodeHollow.FeedReader.Feeds; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Searches.Services; + +public class FeedsService : IEService +{ + private readonly DbService _db; + private readonly ConcurrentDictionary> _subs; + private readonly DiscordSocketClient _client; + private readonly IMessageSenderService _sender; + + private readonly ConcurrentDictionary _lastPosts = new(); + private readonly Dictionary _errorCounters = new(); + + public FeedsService( + IBot bot, + DbService db, + DiscordSocketClient client, + IMessageSenderService sender) + { + _db = db; + + using (var uow = db.GetDbContext()) + { + var guildConfigIds = bot.AllGuildConfigs.Select(x => x.Id).ToList(); + _subs = uow.Set() + .AsQueryable() + .Where(x => guildConfigIds.Contains(x.Id)) + .Include(x => x.FeedSubs) + .ToList() + .SelectMany(x => x.FeedSubs) + .GroupBy(x => x.Url.ToLower()) + .ToDictionary(x => x.Key, x => x.ToList()) + .ToConcurrent(); + } + + _client = client; + _sender = sender; + + _ = Task.Run(TrackFeeds); + } + + private void ClearErrors(string url) + => _errorCounters.Remove(url); + + private async Task AddError(string url, List ids) + { + try + { + var newValue = _errorCounters[url] = _errorCounters.GetValueOrDefault(url) + 1; + + if (newValue >= 100) + { + // remove from db + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .DeleteAsync(x => ids.Contains(x.Id)); + + // remove from the local cache + _subs.TryRemove(url, out _); + + // reset the error counter + ClearErrors(url); + } + + return newValue; + } + catch (Exception ex) + { + Log.Error(ex, "Error adding rss errors..."); + return 0; + } + } + + public async Task TrackFeeds() + { + while (true) + { + var allSendTasks = new List(_subs.Count); + foreach (var kvp in _subs) + { + if (kvp.Value.Count == 0) + continue; + + var rssUrl = kvp.Value.First().Url; + try + { + var feed = await FeedReader.ReadAsync(rssUrl); + + var items = feed + .Items.Select(item => (Item: item, + LastUpdate: item.PublishingDate?.ToUniversalTime() + ?? (item.SpecificItem as AtomFeedItem)?.UpdatedDate?.ToUniversalTime())) + .Where(data => data.LastUpdate is not null) + .Select(data => (data.Item, LastUpdate: (DateTime)data.LastUpdate)) + .OrderByDescending(data => data.LastUpdate) + .Reverse() // start from the oldest + .ToList(); + + if (!_lastPosts.TryGetValue(kvp.Key, out var lastFeedUpdate)) + { + lastFeedUpdate = _lastPosts[kvp.Key] = + items.Any() ? items[items.Count - 1].LastUpdate : DateTime.UtcNow; + } + + foreach (var (feedItem, itemUpdateDate) in items) + { + if (itemUpdateDate <= lastFeedUpdate) + continue; + + var embed = _sender.CreateEmbed().WithFooter(rssUrl); + + _lastPosts[kvp.Key] = itemUpdateDate; + + var link = feedItem.SpecificItem.Link; + if (!string.IsNullOrWhiteSpace(link) && Uri.IsWellFormedUriString(link, UriKind.Absolute)) + embed.WithUrl(link); + + var title = string.IsNullOrWhiteSpace(feedItem.Title) ? "-" : feedItem.Title; + + var gotImage = false; + if (feedItem.SpecificItem is MediaRssFeedItem mrfi + && (mrfi.Enclosure?.MediaType?.StartsWith("image/") ?? false)) + { + var imgUrl = mrfi.Enclosure.Url; + if (!string.IsNullOrWhiteSpace(imgUrl) + && Uri.IsWellFormedUriString(imgUrl, UriKind.Absolute)) + { + embed.WithImageUrl(imgUrl); + gotImage = true; + } + } + + if (!gotImage && feedItem.SpecificItem is AtomFeedItem afi) + { + var previewElement = afi.Element.Elements() + .FirstOrDefault(x => x.Name.LocalName == "preview"); + + if (previewElement is null) + { + previewElement = afi.Element.Elements() + .FirstOrDefault(x => x.Name.LocalName == "thumbnail"); + } + + if (previewElement is not null) + { + var urlAttribute = previewElement.Attribute("url"); + if (urlAttribute is not null + && !string.IsNullOrWhiteSpace(urlAttribute.Value) + && Uri.IsWellFormedUriString(urlAttribute.Value, UriKind.Absolute)) + { + embed.WithImageUrl(urlAttribute.Value); + gotImage = true; + } + } + } + + embed.WithTitle(title.TrimTo(256)); + + var desc = feedItem.Description?.StripHtml(); + if (!string.IsNullOrWhiteSpace(feedItem.Description)) + embed.WithDescription(desc.TrimTo(2048)); + + //send the created embed to all subscribed channels + var feedSendTasks = kvp.Value + .Where(x => x.GuildConfig is not null) + .Select(x => + { + var ch = _client.GetGuild(x.GuildConfig.GuildId) + ?.GetTextChannel(x.ChannelId); + + if (ch is null) + return null; + + return _sender.Response(ch) + .Embed(embed) + .Text(string.IsNullOrWhiteSpace(x.Message) + ? string.Empty + : x.Message) + .SendAsync(); + }) + .Where(x => x is not null); + + allSendTasks.Add(feedSendTasks.WhenAll()); + + // as data retrieval was successful, reset error counter + ClearErrors(rssUrl); + } + } + catch (Exception ex) + { + var errorCount = await AddError(rssUrl, kvp.Value.Select(x => x.Id).ToList()); + + Log.Warning("An error occured while getting rss stream ({ErrorCount} / 100) {RssFeed}" + + "\n {Message}", + errorCount, + rssUrl, + $"[{ex.GetType().Name}]: {ex.Message}"); + } + } + + await Task.WhenAll(Task.WhenAll(allSendTasks), Task.Delay(30000)); + } + } + + public List GetFeeds(ulong guildId) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)) + .FeedSubs.OrderBy(x => x.Id) + .ToList(); + } + + public FeedAddResult AddFeed( + ulong guildId, + ulong channelId, + string rssFeed, + string message) + { + ArgumentNullException.ThrowIfNull(rssFeed, nameof(rssFeed)); + + var fs = new FeedSub + { + ChannelId = channelId, + Url = rssFeed.Trim(), + Message = message + }; + + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)); + + if (gc.FeedSubs.Any(x => x.Url.ToLower() == fs.Url.ToLower())) + return FeedAddResult.Duplicate; + if (gc.FeedSubs.Count >= 10) + return FeedAddResult.LimitReached; + + gc.FeedSubs.Add(fs); + uow.SaveChanges(); + //adding all, in case bot wasn't on this guild when it started + foreach (var feed in gc.FeedSubs) + { + _subs.AddOrUpdate(feed.Url.ToLower(), + [feed], + (_, old) => + { + old.Add(feed); + return old; + }); + } + + return FeedAddResult.Success; + } + + public bool RemoveFeed(ulong guildId, int index) + { + if (index < 0) + return false; + + using var uow = _db.GetDbContext(); + var items = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)) + .FeedSubs.OrderBy(x => x.Id) + .ToList(); + + if (items.Count <= index) + return false; + var toRemove = items[index]; + _subs.AddOrUpdate(toRemove.Url.ToLower(), + [], + (_, old) => + { + old.Remove(toRemove); + return old; + }); + uow.Remove(toRemove); + uow.SaveChanges(); + + return true; + } +} + +public enum FeedAddResult +{ + Success, + LimitReached, + Invalid, + Duplicate, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/JokeCommands.cs b/src/EllieBot/Modules/Searches/JokeCommands.cs new file mode 100644 index 0000000..d41c50a --- /dev/null +++ b/src/EllieBot/Modules/Searches/JokeCommands.cs @@ -0,0 +1,53 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class JokeCommands : EllieModule + { + [Cmd] + public async Task Yomama() + => await Response().Confirm(await _service.GetYomamaJoke()).SendAsync(); + + [Cmd] + public async Task Randjoke() + { + var (setup, punchline) = await _service.GetRandomJoke(); + await Response().Confirm(setup, punchline).SendAsync(); + } + + [Cmd] + public async Task ChuckNorris() + => await Response().Confirm(await _service.GetChuckNorrisJoke()).SendAsync(); + + [Cmd] + public async Task WowJoke() + { + if (!_service.WowJokes.Any()) + { + await Response().Error(strs.jokes_not_loaded).SendAsync(); + return; + } + + var joke = _service.WowJokes[new EllieRandom().Next(0, _service.WowJokes.Count)]; + await Response().Confirm(joke.Question, joke.Answer).SendAsync(); + } + + [Cmd] + public async Task MagicItem() + { + if (!_service.MagicItems.Any()) + { + await Response().Error(strs.magicitems_not_loaded).SendAsync(); + return; + } + + var item = _service.MagicItems[new EllieRandom().Next(0, _service.MagicItems.Count)]; + + await Response().Confirm("✨" + item.Name, item.Description).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/MemegenCommands.cs b/src/EllieBot/Modules/Searches/MemegenCommands.cs new file mode 100644 index 0000000..dbe2679 --- /dev/null +++ b/src/EllieBot/Modules/Searches/MemegenCommands.cs @@ -0,0 +1,99 @@ +#nullable disable +using Newtonsoft.Json; +using System.Collections.Immutable; +using System.Text; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class MemegenCommands : EllieModule + { + private static readonly ImmutableDictionary _map = new Dictionary + { + { '?', "~q" }, + { '%', "~p" }, + { '#', "~h" }, + { '/', "~s" }, + { ' ', "-" }, + { '-', "--" }, + { '_', "__" }, + { '"', "''" } + }.ToImmutableDictionary(); + + private readonly IHttpClientFactory _httpFactory; + + public MemegenCommands(IHttpClientFactory factory) + => _httpFactory = factory; + + [Cmd] + public async Task Memelist(int page = 1) + { + if (--page < 0) + return; + + using var http = _httpFactory.CreateClient("memelist"); + using var res = await http.GetAsync("https://api.memegen.link/templates/"); + + var rawJson = await res.Content.ReadAsStringAsync(); + + var data = JsonConvert.DeserializeObject>(rawJson)!; + + await Response() + .Paginated() + .Items(data) + .PageSize(15) + .CurrentPage(page) + .Page((items, curPage) => + { + var templates = string.Empty; + foreach (var template in items) + templates += $"**{template.Name}:**\n key: `{template.Id}`\n"; + var embed = _sender.CreateEmbed().WithOkColor().WithDescription(templates); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + public async Task Memegen(string meme, [Leftover] string memeText = null) + { + var memeUrl = $"http://api.memegen.link/{meme}"; + if (!string.IsNullOrWhiteSpace(memeText)) + { + var memeTextArray = memeText.Split(';'); + foreach (var text in memeTextArray) + { + var newText = Replace(text); + memeUrl += $"/{newText}"; + } + } + + memeUrl += ".png"; + await Response().Text(memeUrl).SendAsync(); + } + + private static string Replace(string input) + { + var sb = new StringBuilder(); + + foreach (var c in input) + { + if (_map.TryGetValue(c, out var tmp)) + sb.Append(tmp); + else + sb.Append(c); + } + + return sb.ToString(); + } + + private class MemegenTemplate + { + public string Name { get; set; } + public string Id { get; set; } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/OsuCommands.cs b/src/EllieBot/Modules/Searches/OsuCommands.cs new file mode 100644 index 0000000..599df2d --- /dev/null +++ b/src/EllieBot/Modules/Searches/OsuCommands.cs @@ -0,0 +1,297 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class OsuCommands : EllieModule + { + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _httpFactory; + + public OsuCommands(IBotCredentials creds, IHttpClientFactory factory) + { + _creds = creds; + _httpFactory = factory; + } + + [Cmd] + public async Task Osu(string user, [Leftover] string mode = null) + { + if (string.IsNullOrWhiteSpace(user)) + return; + + using var http = _httpFactory.CreateClient(); + var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode); + + try + { + if (string.IsNullOrWhiteSpace(_creds.OsuApiKey)) + { + await Response().Error(strs.osu_api_key).SendAsync(); + return; + } + + var smode = ResolveGameMode(modeNumber); + var userReq = $"https://osu.ppy.sh/api/get_user?k={_creds.OsuApiKey}&u={user}&m={modeNumber}"; + var userResString = await http.GetStringAsync(userReq); + var objs = JsonConvert.DeserializeObject>(userResString); + + if (objs.Count == 0) + { + await Response().Error(strs.osu_user_not_found).SendAsync(); + return; + } + + var obj = objs[0]; + var userId = obj.UserId; + + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle($"osu! {smode} profile for {user}") + .WithThumbnailUrl($"https://a.ppy.sh/{userId}") + .WithDescription($"https://osu.ppy.sh/u/{userId}") + .AddField("Official Rank", $"#{obj.PpRank}", true) + .AddField("Country Rank", + $"#{obj.PpCountryRank} :flag_{obj.Country.ToLower()}:", + true) + .AddField("Total PP", Math.Round(obj.PpRaw, 2), true) + .AddField("Accuracy", Math.Round(obj.Accuracy, 2) + "%", true) + .AddField("Playcount", obj.Playcount, true) + .AddField("Level", Math.Round(obj.Level), true)).SendAsync(); + } + catch (ArgumentOutOfRangeException) + { + await Response().Error(strs.osu_user_not_found).SendAsync(); + } + catch (Exception ex) + { + await Response().Error(strs.osu_failed).SendAsync(); + Log.Warning(ex, "Osu command failed"); + } + } + + [Cmd] + public async Task Gatari(string user, [Leftover] string mode = null) + { + using var http = _httpFactory.CreateClient(); + var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode); + + var modeStr = ResolveGameMode(modeNumber); + var resString = await http.GetStringAsync($"https://api.gatari.pw/user/stats?u={user}&mode={modeNumber}"); + + var statsResponse = JsonConvert.DeserializeObject(resString); + if (statsResponse.Code != 200 || statsResponse.Stats.Id == 0) + { + await Response().Error(strs.osu_user_not_found).SendAsync(); + return; + } + + var usrResString = await http.GetStringAsync($"https://api.gatari.pw/users/get?u={user}"); + + var userData = JsonConvert.DeserializeObject(usrResString).Users[0]; + var userStats = statsResponse.Stats; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"osu!Gatari {modeStr} profile for {user}") + .WithThumbnailUrl($"https://a.gatari.pw/{userStats.Id}") + .WithDescription($"https://osu.gatari.pw/u/{userStats.Id}") + .AddField("Official Rank", $"#{userStats.Rank}", true) + .AddField("Country Rank", + $"#{userStats.CountryRank} :flag_{userData.Country.ToLower()}:", + true) + .AddField("Total PP", userStats.Pp, true) + .AddField("Accuracy", $"{Math.Round(userStats.AvgAccuracy, 2)}%", true) + .AddField("Playcount", userStats.Playcount, true) + .AddField("Level", userStats.Level, true); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Osu5(string user, [Leftover] string mode = null) + { + if (string.IsNullOrWhiteSpace(_creds.OsuApiKey)) + { + await Response().Error("An osu! API key is required.").SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(user)) + { + await Response().Error("Please provide a username.").SendAsync(); + return; + } + + using var http = _httpFactory.CreateClient(); + var m = 0; + if (!string.IsNullOrWhiteSpace(mode)) + m = ResolveGameMode(mode); + + var reqString = "https://osu.ppy.sh/api/get_user_best" + + $"?k={_creds.OsuApiKey}" + + $"&u={Uri.EscapeDataString(user)}" + + "&type=string" + + "&limit=5" + + $"&m={m}"; + + var resString = await http.GetStringAsync(reqString); + var obj = JsonConvert.DeserializeObject>(resString); + + var mapTasks = obj.Select(async item => + { + var mapReqString = "https://osu.ppy.sh/api/get_beatmaps" + + $"?k={_creds.OsuApiKey}" + + $"&b={item.BeatmapId}"; + + var mapResString = await http.GetStringAsync(mapReqString); + var map = JsonConvert.DeserializeObject>(mapResString).FirstOrDefault(); + if (map is null) + return default; + var pp = Math.Round(item.Pp, 2); + var acc = CalculateAcc(item, m); + var mods = ResolveMods(item.EnabledMods); + + var title = $"{map.Artist}-{map.Title} ({map.Version})"; + var desc = $@"[/b/{item.BeatmapId}](https://osu.ppy.sh/b/{item.BeatmapId}) +{pp + "pp",-7} | {acc + "%",-7} +"; + if (mods != "+") + desc += Format.Bold(mods); + + return (title, desc); + }); + + var eb = _sender.CreateEmbed().WithOkColor().WithTitle($"Top 5 plays for {user}"); + + var mapData = await mapTasks.WhenAll(); + foreach (var (title, desc) in mapData.Where(x => x != default)) + eb.AddField(title, desc); + + await Response().Embed(eb).SendAsync(); + } + + //https://osu.ppy.sh/wiki/Accuracy + private static double CalculateAcc(OsuUserBests play, int mode) + { + double hitPoints; + double totalHits; + if (mode == 0) + { + hitPoints = (play.Count50 * 50) + (play.Count100 * 100) + (play.Count300 * 300); + totalHits = play.Count50 + play.Count100 + play.Count300 + play.Countmiss; + totalHits *= 300; + } + else if (mode == 1) + { + hitPoints = (play.Countmiss * 0) + (play.Count100 * 0.5) + play.Count300; + totalHits = (play.Countmiss + play.Count100 + play.Count300) * 300; + hitPoints *= 300; + } + else if (mode == 2) + { + hitPoints = play.Count50 + play.Count100 + play.Count300; + totalHits = play.Countmiss + play.Count50 + play.Count100 + play.Count300 + play.Countkatu; + } + else + { + hitPoints = (play.Count50 * 50) + + (play.Count100 * 100) + + (play.Countkatu * 200) + + ((play.Count300 + play.Countgeki) * 300); + + totalHits = (play.Countmiss + + play.Count50 + + play.Count100 + + play.Countkatu + + play.Count300 + + play.Countgeki) + * 300; + } + + + return Math.Round(hitPoints / totalHits * 100, 2); + } + + private static int ResolveGameMode(string mode) + { + switch (mode.ToUpperInvariant()) + { + case "STD": + case "STANDARD": + return 0; + case "TAIKO": + return 1; + case "CTB": + case "CATCHTHEBEAT": + return 2; + case "MANIA": + case "OSU!MANIA": + return 3; + default: + return 0; + } + } + + private static string ResolveGameMode(int mode) + { + switch (mode) + { + case 0: + return "Standard"; + case 1: + return "Taiko"; + case 2: + return "Catch"; + case 3: + return "Mania"; + default: + return "Standard"; + } + } + + //https://github.com/ppy/osu-api/wiki#mods + private static string ResolveMods(int mods) + { + var modString = "+"; + + if (IsBitSet(mods, 0)) + modString += "NF"; + if (IsBitSet(mods, 1)) + modString += "EZ"; + if (IsBitSet(mods, 8)) + modString += "HT"; + + if (IsBitSet(mods, 3)) + modString += "HD"; + if (IsBitSet(mods, 4)) + modString += "HR"; + if (IsBitSet(mods, 6) && !IsBitSet(mods, 9)) + modString += "DT"; + if (IsBitSet(mods, 9)) + modString += "NC"; + if (IsBitSet(mods, 10)) + modString += "FL"; + + if (IsBitSet(mods, 5)) + modString += "SD"; + if (IsBitSet(mods, 14)) + modString += "PF"; + + if (IsBitSet(mods, 7)) + modString += "RX"; + if (IsBitSet(mods, 11)) + modString += "AT"; + if (IsBitSet(mods, 12)) + modString += "SO"; + return modString; + } + + private static bool IsBitSet(int mods, int pos) + => (mods & (1 << pos)) != 0; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/PathOfExileCommands.cs b/src/EllieBot/Modules/Searches/PathOfExileCommands.cs new file mode 100644 index 0000000..a966a5b --- /dev/null +++ b/src/EllieBot/Modules/Searches/PathOfExileCommands.cs @@ -0,0 +1,312 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using EllieBot.Modules.Searches.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; +using System.Text; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class PathOfExileCommands : EllieModule + { + private const string POE_URL = "https://www.pathofexile.com/character-window/get-characters?accountName="; + private const string PON_URL = "http://poe.ninja/api/Data/GetCurrencyOverview?league="; + private const string POGS_URL = "http://pathofexile.gamepedia.com/api.php?action=opensearch&search="; + + private const string POG_URL = + "https://pathofexile.gamepedia.com/api.php?action=browsebysubject&format=json&subject="; + + private const string POGI_URL = + "https://pathofexile.gamepedia.com/api.php?action=query&prop=imageinfo&iiprop=url&format=json&titles=File:"; + + private const string PROFILE_URL = "https://www.pathofexile.com/account/view-profile/"; + + private readonly IHttpClientFactory _httpFactory; + + private Dictionary currencyDictionary = new(StringComparer.OrdinalIgnoreCase) + { + { "Chaos Orb", "Chaos Orb" }, + { "Orb of Alchemy", "Orb of Alchemy" }, + { "Jeweller's Orb", "Jeweller's Orb" }, + { "Exalted Orb", "Exalted Orb" }, + { "Mirror of Kalandra", "Mirror of Kalandra" }, + { "Vaal Orb", "Vaal Orb" }, + { "Orb of Alteration", "Orb of Alteration" }, + { "Orb of Scouring", "Orb of Scouring" }, + { "Divine Orb", "Divine Orb" }, + { "Orb of Annulment", "Orb of Annulment" }, + { "Master Cartographer's Sextant", "Master Cartographer's Sextant" }, + { "Journeyman Cartographer's Sextant", "Journeyman Cartographer's Sextant" }, + { "Apprentice Cartographer's Sextant", "Apprentice Cartographer's Sextant" }, + { "Blessed Orb", "Blessed Orb" }, + { "Orb of Regret", "Orb of Regret" }, + { "Gemcutter's Prism", "Gemcutter's Prism" }, + { "Glassblower's Bauble", "Glassblower's Bauble" }, + { "Orb of Fusing", "Orb of Fusing" }, + { "Cartographer's Chisel", "Cartographer's Chisel" }, + { "Chromatic Orb", "Chromatic Orb" }, + { "Orb of Augmentation", "Orb of Augmentation" }, + { "Blacksmith's Whetstone", "Blacksmith's Whetstone" }, + { "Orb of Transmutation", "Orb of Transmutation" }, + { "Armourer's Scrap", "Armourer's Scrap" }, + { "Scroll of Wisdom", "Scroll of Wisdom" }, + { "Regal Orb", "Regal Orb" }, + { "Chaos", "Chaos Orb" }, + { "Alch", "Orb of Alchemy" }, + { "Alchs", "Orb of Alchemy" }, + { "Jews", "Jeweller's Orb" }, + { "Jeweller", "Jeweller's Orb" }, + { "Jewellers", "Jeweller's Orb" }, + { "Jeweller's", "Jeweller's Orb" }, + { "X", "Exalted Orb" }, + { "Ex", "Exalted Orb" }, + { "Exalt", "Exalted Orb" }, + { "Exalts", "Exalted Orb" }, + { "Mirror", "Mirror of Kalandra" }, + { "Mirrors", "Mirror of Kalandra" }, + { "Vaal", "Vaal Orb" }, + { "Alt", "Orb of Alteration" }, + { "Alts", "Orb of Alteration" }, + { "Scour", "Orb of Scouring" }, + { "Scours", "Orb of Scouring" }, + { "Divine", "Divine Orb" }, + { "Annul", "Orb of Annulment" }, + { "Annulment", "Orb of Annulment" }, + { "Master Sextant", "Master Cartographer's Sextant" }, + { "Journeyman Sextant", "Journeyman Cartographer's Sextant" }, + { "Apprentice Sextant", "Apprentice Cartographer's Sextant" }, + { "Blessed", "Blessed Orb" }, + { "Regret", "Orb of Regret" }, + { "Regrets", "Orb of Regret" }, + { "Gcp", "Gemcutter's Prism" }, + { "Glassblowers", "Glassblower's Bauble" }, + { "Glassblower's", "Glassblower's Bauble" }, + { "Fusing", "Orb of Fusing" }, + { "Fuses", "Orb of Fusing" }, + { "Fuse", "Orb of Fusing" }, + { "Chisel", "Cartographer's Chisel" }, + { "Chisels", "Cartographer's Chisel" }, + { "Chance", "Orb of Chance" }, + { "Chances", "Orb of Chance" }, + { "Chrome", "Chromatic Orb" }, + { "Chromes", "Chromatic Orb" }, + { "Aug", "Orb of Augmentation" }, + { "Augmentation", "Orb of Augmentation" }, + { "Augment", "Orb of Augmentation" }, + { "Augments", "Orb of Augmentation" }, + { "Whetstone", "Blacksmith's Whetstone" }, + { "Whetstones", "Blacksmith's Whetstone" }, + { "Transmute", "Orb of Transmutation" }, + { "Transmutes", "Orb of Transmutation" }, + { "Armourers", "Armourer's Scrap" }, + { "Armourer's", "Armourer's Scrap" }, + { "Wisdom Scroll", "Scroll of Wisdom" }, + { "Wisdom Scrolls", "Scroll of Wisdom" }, + { "Regal", "Regal Orb" }, + { "Regals", "Regal Orb" } + }; + + public PathOfExileCommands(IHttpClientFactory httpFactory) + => _httpFactory = httpFactory; + + [Cmd] + public async Task PathOfExile(string usr, string league = "", int page = 1) + { + if (--page < 0) + return; + + if (string.IsNullOrWhiteSpace(usr)) + { + await Response().Error("Please provide an account name.").SendAsync(); + return; + } + + var characters = new List(); + + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"{POE_URL}{usr}"); + characters = JsonConvert.DeserializeObject>(res); + } + catch + { + var embed = _sender.CreateEmbed().WithDescription(GetText(strs.account_not_found)).WithErrorColor(); + + await Response().Embed(embed).SendAsync(); + return; + } + + if (!string.IsNullOrWhiteSpace(league)) + characters.RemoveAll(c => c.League != league); + + await Response() + .Paginated() + .Items(characters) + .PageSize(9) + .CurrentPage(page) + .Page((items, curPage) => + { + var embed = _sender.CreateEmbed() + .WithAuthor($"Characters on {usr}'s account", + "https://web.poecdn.com/image/favicon/ogimage.png", + $"{PROFILE_URL}{usr}") + .WithOkColor(); + + if (characters.Count == 0) + return embed.WithDescription("This account has no characters."); + + var sb = new StringBuilder(); + sb.AppendLine($"```{"#",-5}{"Character Name",-23}{"League",-10}{"Class",-13}{"Level",-3}"); + for (var i = 0; i < items.Count; i++) + { + var character = items[i]; + + sb.AppendLine( + $"#{i + 1 + (curPage * 9),-4}{character.Name,-23}{ShortLeagueName(character.League),-10}{character.Class,-13}{character.Level,-3}"); + } + + sb.AppendLine("```"); + embed.WithDescription(sb.ToString()); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + public async Task PathOfExileLeagues() + { + var leagues = new List(); + + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync("http://api.pathofexile.com/leagues?type=main&compact=1"); + leagues = JsonConvert.DeserializeObject>(res); + } + catch + { + var eembed = _sender.CreateEmbed().WithDescription(GetText(strs.leagues_not_found)).WithErrorColor(); + + await Response().Embed(eembed).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithAuthor("Path of Exile Leagues", + "https://web.poecdn.com/image/favicon/ogimage.png", + "https://www.pathofexile.com") + .WithOkColor(); + + var sb = new StringBuilder(); + sb.AppendLine($"```{"#",-5}{"League Name",-23}"); + for (var i = 0; i < leagues.Count; i++) + { + var league = leagues[i]; + + sb.AppendLine($"#{i + 1,-4}{league.Id,-23}"); + } + + sb.AppendLine("```"); + + embed.WithDescription(sb.ToString()); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task PathOfExileCurrency( + string leagueName, + string currencyName, + string convertName = "Chaos Orb") + { + if (string.IsNullOrWhiteSpace(leagueName)) + { + await Response().Error("Please provide league name.").SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(currencyName)) + { + await Response().Error("Please provide currency name.").SendAsync(); + return; + } + + var cleanCurrency = ShortCurrencyName(currencyName); + var cleanConvert = ShortCurrencyName(convertName); + + try + { + var res = $"{PON_URL}{leagueName}"; + using var http = _httpFactory.CreateClient(); + var obj = JObject.Parse(await http.GetStringAsync(res)); + + var chaosEquivalent = 0.0F; + var conversionEquivalent = 0.0F; + + // poe.ninja API does not include a "chaosEquivalent" property for Chaos Orbs. + if (cleanCurrency == "Chaos Orb") + chaosEquivalent = 1.0F; + else + { + var currencyInput = obj["lines"] + .Values() + .Where(i => i["currencyTypeName"].Value() == cleanCurrency) + .FirstOrDefault(); + chaosEquivalent = float.Parse(currencyInput["chaosEquivalent"].ToString(), + CultureInfo.InvariantCulture); + } + + if (cleanConvert == "Chaos Orb") + conversionEquivalent = 1.0F; + else + { + var currencyOutput = obj["lines"] + .Values() + .Where(i => i["currencyTypeName"].Value() == cleanConvert) + .FirstOrDefault(); + conversionEquivalent = float.Parse(currencyOutput["chaosEquivalent"].ToString(), + CultureInfo.InvariantCulture); + } + + var embed = _sender.CreateEmbed() + .WithAuthor($"{leagueName} Currency Exchange", + "https://web.poecdn.com/image/favicon/ogimage.png", + "http://poe.ninja") + .AddField("Currency Type", cleanCurrency, true) + .AddField($"{cleanConvert} Equivalent", chaosEquivalent / conversionEquivalent, true) + .WithOkColor(); + + await Response().Embed(embed).SendAsync(); + } + catch + { + var embed = _sender.CreateEmbed().WithDescription(GetText(strs.ninja_not_found)).WithErrorColor(); + + await Response().Embed(embed).SendAsync(); + } + } + + private string ShortCurrencyName(string str) + { + if (currencyDictionary.ContainsValue(str)) + return str; + + var currency = currencyDictionary[str]; + + return currency; + } + + private static string ShortLeagueName(string str) + { + var league = str.Replace("Hardcore", "HC", StringComparison.InvariantCultureIgnoreCase); + + return league; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs b/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs new file mode 100644 index 0000000..6250e87 --- /dev/null +++ b/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs @@ -0,0 +1,74 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class PokemonSearchCommands : EllieModule + { + private readonly ILocalDataCache _cache; + + public PokemonSearchCommands(ILocalDataCache cache) + => _cache = cache; + + [Cmd] + public async Task Pokemon([Leftover] string pokemon = null) + { + pokemon = pokemon?.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(pokemon)) + return; + + foreach (var kvp in await _cache.GetPokemonsAsync()) + { + if (kvp.Key.ToUpperInvariant() == pokemon.ToUpperInvariant()) + { + var p = kvp.Value; + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(kvp.Key.ToTitleCase()) + .WithDescription(p.BaseStats.ToString()) + .WithThumbnailUrl( + $"https://assets.pokemon.com/assets/cms2/img/pokedex/detail/{p.Id.ToString("000")}.png") + .AddField(GetText(strs.types), string.Join("\n", p.Types), true) + .AddField(GetText(strs.height_weight), + GetText(strs.height_weight_val(p.HeightM, p.WeightKg)), + true) + .AddField(GetText(strs.abilities), + string.Join("\n", p.Abilities.Select(a => a.Value)), + true)).SendAsync(); + return; + } + } + + await Response().Error(strs.pokemon_none).SendAsync(); + } + + [Cmd] + public async Task PokemonAbility([Leftover] string ability = null) + { + ability = ability?.Trim().ToUpperInvariant().Replace(" ", "", StringComparison.InvariantCulture); + if (string.IsNullOrWhiteSpace(ability)) + return; + foreach (var kvp in await _cache.GetPokemonAbilitiesAsync()) + { + if (kvp.Key.ToUpperInvariant() == ability) + { + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(kvp.Value.Name) + .WithDescription(string.IsNullOrWhiteSpace(kvp.Value.Desc) + ? kvp.Value.ShortDesc + : kvp.Value.Desc) + .AddField(GetText(strs.rating), + kvp.Value.Rating.ToString(Culture), + true)).SendAsync(); + return; + } + } + + await Response().Error(strs.pokemon_ability_none).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs b/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs new file mode 100644 index 0000000..fa3c634 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs @@ -0,0 +1,65 @@ +using EllieBot.Modules.Searches.GoogleScrape; +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public sealed class DefaultSearchServiceFactory : ISearchServiceFactory, IEService +{ + private readonly SearchesConfigService _scs; + private readonly SearxSearchService _sss; + private readonly GoogleSearchService _gss; + + private readonly YtdlpYoutubeSearchService _ytdlp; + private readonly YtdlYoutubeSearchService _ytdl; + private readonly YoutubeDataApiSearchService _ytdata; + private readonly InvidiousYtSearchService _iYtSs; + private readonly GoogleScrapeService _gscs; + + public DefaultSearchServiceFactory( + SearchesConfigService scs, + GoogleSearchService gss, + GoogleScrapeService gscs, + SearxSearchService sss, + YtdlpYoutubeSearchService ytdlp, + YtdlYoutubeSearchService ytdl, + YoutubeDataApiSearchService ytdata, + InvidiousYtSearchService iYtSs) + { + _scs = scs; + _sss = sss; + _gss = gss; + _gscs = gscs; + _iYtSs = iYtSs; + + _ytdlp = ytdlp; + _ytdl = ytdl; + _ytdata = ytdata; + } + + public ISearchService GetSearchService(string? hint = null) + => _scs.Data.WebSearchEngine switch + { + WebSearchEngine.Google => _gss, + WebSearchEngine.Google_Scrape => _gscs, + WebSearchEngine.Searx => _sss, + _ => _gss + }; + + public ISearchService GetImageSearchService(string? hint = null) + => _scs.Data.ImgSearchEngine switch + { + ImgSearchEngine.Google => _gss, + ImgSearchEngine.Searx => _sss, + _ => _gss + }; + + public IYoutubeSearchService GetYoutubeSearchService(string? hint = null) + => _scs.Data.YtProvider switch + { + YoutubeSearcher.YtDataApiv3 => _ytdata, + YoutubeSearcher.Ytdlp => _ytdlp, + YoutubeSearcher.Ytdl => _ytdl, + YoutubeSearcher.Invidious => _iYtSs, + _ => _ytdl + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs new file mode 100644 index 0000000..74fd3c0 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs @@ -0,0 +1,22 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleCustomSearchResult : ISearchResult +{ + ISearchResultInformation ISearchResult.Info + => Info; + + public string? Answer + => null; + + IReadOnlyCollection ISearchResult.Entries + => Entries ?? Array.Empty(); + + [JsonPropertyName("searchInformation")] + public GoogleSearchResultInformation Info { get; init; } = null!; + + [JsonPropertyName("items")] + public IReadOnlyCollection? Entries { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs new file mode 100644 index 0000000..503a1cc --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageData +{ + [JsonPropertyName("contextLink")] + public string ContextLink { get; init; } = null!; + + [JsonPropertyName("thumbnailLink")] + public string ThumbnailLink { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs new file mode 100644 index 0000000..9cf406b --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs @@ -0,0 +1,19 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageResult : IImageSearchResult +{ + ISearchResultInformation IImageSearchResult.Info + => Info; + + IReadOnlyCollection IImageSearchResult.Entries + => Entries ?? Array.Empty(); + + [JsonPropertyName("searchInformation")] + public GoogleSearchResultInformation Info { get; init; } = null!; + + [JsonPropertyName("items")] + public IReadOnlyCollection? Entries { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs new file mode 100644 index 0000000..cd06fae --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs @@ -0,0 +1,13 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageResultEntry : IImageSearchResultEntry +{ + [JsonPropertyName("link")] + public string Link { get; init; } = null!; + + [JsonPropertyName("image")] + public GoogleImageData Image { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs new file mode 100644 index 0000000..0106c0a --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs @@ -0,0 +1,13 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleSearchResultInformation : ISearchResultInformation +{ + [JsonPropertyName("formattedTotalResults")] + public string TotalResults { get; init; } = null!; + + [JsonPropertyName("formattedSearchTime")] + public string SearchTime { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs new file mode 100644 index 0000000..c74d746 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs @@ -0,0 +1,66 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public sealed class GoogleSearchService : SearchServiceBase, IEService +{ + private readonly IBotCredsProvider _creds; + private readonly IHttpClientFactory _httpFactory; + + public GoogleSearchService(IBotCredsProvider creds, IHttpClientFactory httpFactory) + { + _creds = creds; + _httpFactory = httpFactory; + } + + public override async ITask SearchImagesAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var creds = _creds.GetCreds(); + var key = creds.Google.ImageSearchId; + var cx = string.IsNullOrWhiteSpace(key) + ? "c3f56de3be2034c07" + : key; + + using var http = _httpFactory.CreateClient("google:search"); + http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip"); + await using var stream = await http.GetStreamAsync( + $"https://customsearch.googleapis.com/customsearch/v1" + + $"?cx={cx}" + + $"&q={Uri.EscapeDataString(query)}" + + $"&fields=items(image(contextLink%2CthumbnailLink)%2Clink)%2CsearchInformation" + + $"&key={creds.GoogleApiKey}" + + $"&searchType=image" + + $"&safe=active"); + + var result = await System.Text.Json.JsonSerializer.DeserializeAsync(stream); + + return result; + } + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + var creds = _creds.GetCreds(); + var key = creds.Google.SearchId; + var cx = string.IsNullOrWhiteSpace(key) + ? "c7f1dac95987d4571" + : key; + + using var http = _httpFactory.CreateClient("google:search"); + http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip"); + await using var stream = await http.GetStreamAsync( + $"https://customsearch.googleapis.com/customsearch/v1" + + $"?cx={cx}" + + $"&q={Uri.EscapeDataString(query)}" + + $"&fields=items(title%2Clink%2CdisplayLink%2Csnippet)%2CsearchInformation" + + $"&key={creds.GoogleApiKey}" + + $"&safe=active"); + + var result = await System.Text.Json.JsonSerializer.DeserializeAsync(stream); + + return result; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs new file mode 100644 index 0000000..bf23180 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs @@ -0,0 +1,19 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class OfficialGoogleSearchResultEntry : ISearchResultEntry +{ + [JsonPropertyName("title")] + public string Title { get; init; } = null!; + + [JsonPropertyName("link")] + public string Url { get; init; } = null!; + + [JsonPropertyName("displayLink")] + public string DisplayUrl { get; init; } = null!; + + [JsonPropertyName("snippet")] + public string Description { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs new file mode 100644 index 0000000..8c20767 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs @@ -0,0 +1,121 @@ +using AngleSharp.Html.Dom; +using AngleSharp.Html.Parser; +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class GoogleScrapeService : SearchServiceBase, IEService +{ + private static readonly HtmlParser _googleParser = new(new() + { + IsScripting = false, + IsEmbedded = false, + IsSupportingProcessingInstructions = false, + IsKeepingSourceReferences = false, + IsNotSupportingFrames = true + }); + + + private readonly IHttpClientFactory _httpFactory; + + public GoogleScrapeService(IHttpClientFactory httpClientFactory) + => _httpFactory = httpClientFactory; + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + query = Uri.EscapeDataString(query)?.Replace(' ', '+'); + + var fullQueryLink = $"https://www.google.ca/search?q={query}&safe=on&lr=lang_eng&hl=en&ie=utf-8&oe=utf-8"; + + using var msg = new HttpRequestMessage(HttpMethod.Get, fullQueryLink); + msg.Headers.Add("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36"); + msg.Headers.Add("Cookie", "CONSENT=YES+shp.gws-20210601-0-RC2.en+FX+423;"); + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + + using var response = await http.SendAsync(msg); + await using var content = await response.Content.ReadAsStreamAsync(); + + using var document = await _googleParser.ParseDocumentAsync(content); + var elems = document.QuerySelectorAll("div.g, div.mnr-c > div > div"); + + var resultsElem = document.QuerySelector("#result-stats"); + var resultsArr = resultsElem?.TextContent.Split("results"); + var totalResults = resultsArr?.Length is null or 0 + ? null + : resultsArr[0]; + + var time = resultsArr is null or {Length: < 2} + ? null + : resultsArr[1] + .Replace("(", string.Empty) + .Replace("seconds)", string.Empty); + + //var time = resultsElem.Children.FirstOrDefault()?.TextContent + //^ this doesn't work for some reason, is completely missing in parsed collection + if (!elems.Any()) + return default; + + var results = elems.Select(elem => + { + var aTag = elem.QuerySelector("a"); + + if (aTag is null) + return null; + + var url = ((IHtmlAnchorElement)aTag).Href; + var title = aTag.QuerySelector("h3")?.TextContent; + + var txt = aTag.ParentElement + ?.NextElementSibling + ?.QuerySelector("span") + ?.TextContent + .StripHtml() + ?? elem + ?.QuerySelectorAll("span") + .Skip(3) + .FirstOrDefault() + ?.TextContent + .StripHtml(); + // .Select(x => x.TextContent.StripHtml()) + // .Join("\n"); + + if (string.IsNullOrWhiteSpace(url) + || string.IsNullOrWhiteSpace(title) + || string.IsNullOrWhiteSpace(txt)) + return null; + + return new PlainSearchResultEntry + { + Title = title, + Url = url, + DisplayUrl = url, + Description = txt + }; + }) + .Where(x => x is not null) + .ToList(); + + // return new GoogleSearchResult(results.AsReadOnly(), fullQueryLink, totalResults); + + return new PlainGoogleScrapeSearchResult() + { + Answer = null, + Entries = results!, + Info = new PlainSearchResultInfo() + { + SearchTime = time ?? "?", + TotalResults = totalResults ?? "?" + } + }; + } + + + // someone can mr this + public override ITask SearchImagesAsync(string query) + => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs new file mode 100644 index 0000000..9abc999 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public class PlainGoogleScrapeSearchResult : ISearchResult +{ + public required string? Answer { get; init; } + public required IReadOnlyCollection Entries { get; init; } + public required ISearchResultInformation Info { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs new file mode 100644 index 0000000..99fad02 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class PlainSearchResultEntry : ISearchResultEntry +{ + public string Title { get; init; } = null!; + public string Url { get; init; } = null!; + public string DisplayUrl { get; init; } = null!; + public string? Description { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs new file mode 100644 index 0000000..92ba006 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class PlainSearchResultInfo : ISearchResultInformation +{ + public string TotalResults { get; init; } = null!; + public string SearchTime { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs b/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs new file mode 100644 index 0000000..d470613 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Modules.Searches; + +public interface IImageSearchResult +{ + ISearchResultInformation Info { get; } + + IReadOnlyCollection Entries { get; } +} + +public interface IImageSearchResultEntry +{ + string Link { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResult.cs b/src/EllieBot/Modules/Searches/Search/ISearchResult.cs new file mode 100644 index 0000000..d910819 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResult +{ + string? Answer { get; } + IReadOnlyCollection Entries { get; } + ISearchResultInformation Info { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs new file mode 100644 index 0000000..e4dfc44 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResultEntry +{ + string Title { get; } + string Url { get; } + string DisplayUrl { get; } + string? Description { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs new file mode 100644 index 0000000..dfd9a53 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResultInformation +{ + string TotalResults { get; } + string SearchTime { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchService.cs b/src/EllieBot/Modules/Searches/Search/ISearchService.cs new file mode 100644 index 0000000..7454a60 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchService.cs @@ -0,0 +1,9 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public interface ISearchService +{ + ITask SearchAsync(string? query); + ITask SearchImagesAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs b/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs new file mode 100644 index 0000000..bb46b09 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs @@ -0,0 +1,10 @@ +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public interface ISearchServiceFactory +{ + public ISearchService GetSearchService(string? hint = null); + public ISearchService GetImageSearchService(string? hint = null); + public IYoutubeSearchService GetYoutubeSearchService(string? hint = null); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/SearchCommands.cs b/src/EllieBot/Modules/Searches/Search/SearchCommands.cs new file mode 100644 index 0000000..6a98eac --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/SearchCommands.cs @@ -0,0 +1,202 @@ +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + public partial class SearchCommands : EllieModule + { + private readonly ISearchServiceFactory _searchFactory; + private readonly IBotCache _cache; + + public SearchCommands( + ISearchServiceFactory searchFactory, + IBotCache cache) + { + _searchFactory = searchFactory; + _cache = cache; + } + + [Cmd] + public async Task Google([Leftover] string? query = null) + { + query = query?.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var search = _searchFactory.GetSearchService(); + var data = await search.SearchAsync(query); + + if (data is null or { Entries: null or { Count: 0 } }) + { + await Response().Error(strs.no_results).SendAsync(); + return; + } + + // 3 with an answer + // 4 without an answer + // 5 is ideal but it lookes horrible on mobile + + var takeCount = string.IsNullOrWhiteSpace(data.Answer) + ? 4 + : 3; + + var descStr = data.Entries + .Take(takeCount) + .Select(static res => $@"**[{Format.Sanitize(res.Title)}]({res.Url})** +*{Format.EscapeUrl(res.DisplayUrl)}* +{Format.Sanitize(res.Description ?? "-")}") + .Join("\n\n"); + + if (!string.IsNullOrWhiteSpace(data.Answer)) + descStr = Format.Code(data.Answer) + "\n\n" + descStr; + + descStr = descStr.TrimTo(4096); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithTitle(query.TrimTo(64)!) + .WithDescription(descStr) + .WithFooter( + GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)), + "https://i.imgur.com/G46fm8J.png"); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Image([Leftover] string? query = null) + { + query = query?.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var search = _searchFactory.GetImageSearchService(); + var data = await search.SearchImagesAsync(query); + + if (data is null or { Entries: null or { Count: 0 } }) + { + await Response().Error(strs.no_search_results).SendAsync(); + return; + } + + var embeds = new List(4); + + + EmbedBuilder CreateEmbed(IImageSearchResultEntry entry) + { + return _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithTitle(query) + .WithUrl("https://google.com") + .WithImageUrl(entry.Link); + } + + embeds.Add(CreateEmbed(data.Entries.First()) + .WithFooter( + GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)), + "https://i.imgur.com/G46fm8J.png")); + + var random = data.Entries.Skip(1) + .Shuffle() + .Take(3) + .ToArray(); + + foreach (var entry in random) + { + embeds.Add(CreateEmbed(entry)); + } + + await Response().Embeds(embeds).SendAsync(); + } + + private TypedKey GetYtCacheKey(string query) + => new($"search:youtube:{query}"); + + private async Task AddYoutubeUrlToCacheAsync(string query, string url) + => await _cache.AddAsync(GetYtCacheKey(query), url, expiry: 1.Hours()); + + private async Task GetYoutubeUrlFromCacheAsync(string query) + { + var result = await _cache.GetAsync(GetYtCacheKey(query)); + + if (!result.TryGetValue(out var url) || string.IsNullOrWhiteSpace(url)) + return null; + + return new VideoInfo() + { + Url = url + }; + } + + [Cmd] + public async Task Youtube([Leftover] string? query = null) + { + query = query?.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var maybeResult = await GetYoutubeUrlFromCacheAsync(query) + ?? await _searchFactory.GetYoutubeSearchService().SearchAsync(query); + if (maybeResult is not {} result || result is {Url: null}) + { + await Response().Error(strs.no_results).SendAsync(); + return; + } + + await AddYoutubeUrlToCacheAsync(query, result.Url); + await Response().Text(result.Url).SendAsync(); + } + +// [Cmd] +// public async Task DuckDuckGo([Leftover] string query = null) +// { +// query = query?.Trim(); +// if (!await ValidateQuery(query)) +// return; +// +// _ = ctx.Channel.TriggerTypingAsync(); +// +// var data = await _service.DuckDuckGoSearchAsync(query); +// if (data is null) +// { +// await Response().Error(strs.no_results).SendAsync(); +// return; +// } +// +// var desc = data.Results.Take(5) +// .Select(res => $@"[**{res.Title}**]({res.Link}) +// {res.Text.TrimTo(380 - res.Title.Length - res.Link.Length)}"); +// +// var descStr = string.Join("\n\n", desc); +// +// var embed = _sender.CreateEmbed() +// .WithAuthor(ctx.User.ToString(), +// "https://upload.wikimedia.org/wikipedia/en/9/90/The_DuckDuckGo_Duck.png") +// .WithDescription($"{GetText(strs.search_for)} **{query}**\n\n" + descStr) +// .WithOkColor(); +// +// await Response().Embed(embed).SendAsync(); +// } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs b/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs new file mode 100644 index 0000000..c346306 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs @@ -0,0 +1,9 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public abstract class SearchServiceBase : ISearchService +{ + public abstract ITask SearchAsync(string? query); + public abstract ITask SearchImagesAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs new file mode 100644 index 0000000..54fdcdd --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs @@ -0,0 +1,28 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxImageSearchResult : IImageSearchResult +{ + public string SearchTime { get; set; } = null!; + + public ISearchResultInformation Info + => new SearxSearchResultInformation() + { + SearchTime = SearchTime, + TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture) + }; + + public IReadOnlyCollection Entries + => Results; + + [JsonPropertyName("results")] + public List Results { get; set; } = new List(); + + [JsonPropertyName("query")] + public string Query { get; set; } = null!; + + [JsonPropertyName("number_of_results")] + public double NumberOfResults { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs new file mode 100644 index 0000000..888a2ce --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxImageSearchResultEntry : IImageSearchResultEntry +{ + public string Link + => ImageSource.StartsWith("//") + ? "https:" + ImageSource + : ImageSource; + + [JsonPropertyName("img_src")] + public string ImageSource { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs new file mode 100644 index 0000000..1fd9ee2 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs @@ -0,0 +1,30 @@ +// using System.Text.Json.Serialization; +// +// namespace EllieBot.Modules.Searches; +// +// public sealed class SearxInfobox +// { +// [JsonPropertyName("infobox")] +// public string Infobox { get; set; } +// +// [JsonPropertyName("id")] +// public string Id { get; set; } +// +// [JsonPropertyName("content")] +// public string Content { get; set; } +// +// [JsonPropertyName("img_src")] +// public string ImgSrc { get; set; } +// +// [JsonPropertyName("urls")] +// public List Urls { get; } = new List(); +// +// [JsonPropertyName("engine")] +// public string Engine { get; set; } +// +// [JsonPropertyName("engines")] +// public List Engines { get; } = new List(); +// +// [JsonPropertyName("attributes")] +// public List Attributes { get; } = new List(); +// } \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs new file mode 100644 index 0000000..7071ea7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchAttribute +{ + [JsonPropertyName("label")] + public string? Label { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("entity")] + public string? Entity { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs new file mode 100644 index 0000000..3483548 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResult : ISearchResult +{ + [JsonPropertyName("query")] + public string Query { get; set; } = null!; + + [JsonPropertyName("number_of_results")] + public double NumberOfResults { get; set; } + + [JsonPropertyName("results")] + public List Results { get; set; } = new List(); + + [JsonPropertyName("answers")] + public List Answers { get; set; } = new List(); + // + // [JsonPropertyName("corrections")] + // public List Corrections { get; } = new List(); + + // [JsonPropertyName("infoboxes")] + // public List Infoboxes { get; } = new List(); + // + // [JsonPropertyName("suggestions")] + // public List Suggestions { get; } = new List(); + + // [JsonPropertyName("unresponsive_engines")] + // public List UnresponsiveEngines { get; } = new List(); + + + public string SearchTime { get; set; } = null!; + + public IReadOnlyCollection Entries + => Results; + + public ISearchResultInformation Info + => new SearxSearchResultInformation() + { + SearchTime = SearchTime, + TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture) + }; + + public string? Answer + => Answers.FirstOrDefault(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs new file mode 100644 index 0000000..9670a17 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResultEntry : ISearchResultEntry +{ + public string DisplayUrl + => Url; + + public string Description + => Content.TrimTo(768)!; + + [JsonPropertyName("url")] + public string Url { get; set; } = null!; + + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + [JsonPropertyName("content")] + public string? Content { get; set; } + + // [JsonPropertyName("engine")] + // public string Engine { get; set; } + // + // [JsonPropertyName("parsed_url")] + // public List ParsedUrl { get; } = new List(); + // + // [JsonPropertyName("template")] + // public string Template { get; set; } + // + // [JsonPropertyName("engines")] + // public List Engines { get; } = new List(); + // + // [JsonPropertyName("positions")] + // public List Positions { get; } = new List(); + // + // [JsonPropertyName("score")] + // public double Score { get; set; } + // + // [JsonPropertyName("category")] + // public string Category { get; set; } + // + // [JsonPropertyName("pretty_url")] + // public string PrettyUrl { get; set; } + // + // [JsonPropertyName("open_group")] + // public bool OpenGroup { get; set; } + // + // [JsonPropertyName("close_group")] + // public bool? CloseGroup { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs new file mode 100644 index 0000000..33b8077 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResultInformation : ISearchResultInformation +{ + public string TotalResults { get; init; } = string.Empty; + public string SearchTime { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs new file mode 100644 index 0000000..87e4103 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs @@ -0,0 +1,76 @@ +using MorseCode.ITask; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchService : SearchServiceBase, IEService +{ + private readonly IHttpClientFactory _http; + private readonly SearchesConfigService _scs; + + private static readonly Random _rng = new EllieRandom(); + + public SearxSearchService(IHttpClientFactory http, SearchesConfigService scs) + => (_http, _scs) = (http, scs); + + private string GetRandomInstance() + { + var instances = _scs.Data.SearxInstances; + + if (instances is null or { Count: 0 }) + throw new InvalidOperationException("No searx instances specified in searches.yml"); + + return instances[_rng.Next(0, instances.Count)]; + } + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + var instanceUrl = GetRandomInstance(); + + Log.Information("Using {Instance} instance for web search...", instanceUrl); + var sw = Stopwatch.StartNew(); + using var http = _http.CreateClient(); + await using var res = await http.GetStreamAsync($"{instanceUrl}" + + $"?q={Uri.EscapeDataString(query)}" + + $"&format=json" + + $"&strict=2"); + + sw.Stop(); + var dat = await JsonSerializer.DeserializeAsync(res); + + if (dat is null) + return new SearxSearchResult(); + + dat.SearchTime = sw.Elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture); + return dat; + } + + public override async ITask SearchImagesAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var instanceUrl = GetRandomInstance(); + + Log.Information("Using {Instance} instance for img search...", instanceUrl); + var sw = Stopwatch.StartNew(); + using var http = _http.CreateClient(); + await using var res = await http.GetStreamAsync($"{instanceUrl}" + + $"?q={Uri.EscapeDataString(query)}" + + $"&format=json" + + $"&category_images=on" + + $"&strict=2"); + + sw.Stop(); + var dat = await JsonSerializer.DeserializeAsync(res); + + if (dat is null) + return new SearxImageSearchResult(); + + dat.SearchTime = sw.Elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture); + return dat; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs new file mode 100644 index 0000000..07f8591 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxUrlData +{ + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + [JsonPropertyName("url")] + public string Url { get; set; } = null!; + + [JsonPropertyName("official")] + public bool? Official { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs new file mode 100644 index 0000000..5b9bfab --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public interface IYoutubeSearchService +{ + Task SearchAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs new file mode 100644 index 0000000..9951db8 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class InvidiousSearchResponse +{ + [JsonPropertyName("videoId")] + public string VideoId { get; set; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs new file mode 100644 index 0000000..6fc8bac --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs @@ -0,0 +1,46 @@ +using EllieBot.Modules.Searches.Youtube; +using System.Net.Http.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class InvidiousYtSearchService : IYoutubeSearchService, IEService +{ + private readonly IHttpClientFactory _http; + private readonly SearchesConfigService _scs; + private readonly EllieRandom _rng; + + public InvidiousYtSearchService( + IHttpClientFactory http, + SearchesConfigService scs) + { + _http = http; + _scs = scs; + _rng = new(); + } + + public async Task SearchAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var instances = _scs.Data.InvidiousInstances; + if (instances is null or { Count: 0 }) + { + Log.Warning("Attempted to use Invidious as the .youtube provider but there are no 'invidiousInstances' " + + "specified in `data/searches.yml`"); + return null; + } + + var instance = instances[_rng.Next(0, instances.Count)]; + + using var http = _http.CreateClient(); + var res = await http.GetFromJsonAsync>( + $"{instance}/api/v1/search" + + $"?q={query}" + + $"&type=video"); + + if (res is null or {Count: 0}) + return null; + + return new VideoInfo(res[0].VideoId); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs b/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs new file mode 100644 index 0000000..5f53b9b --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public readonly struct VideoInfo +{ + public VideoInfo(string videoId) + => Url = $"https://youtube.com/watch?v={videoId}"; + + public string Url { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs new file mode 100644 index 0000000..e8bfcd2 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs @@ -0,0 +1,26 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YoutubeDataApiSearchService : IYoutubeSearchService, IEService +{ + private readonly IGoogleApiService _gapi; + + public YoutubeDataApiSearchService(IGoogleApiService gapi) + { + _gapi = gapi; + } + + public async Task SearchAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var results = await _gapi.GetVideoLinksByKeywordAsync(query); + var first = results.FirstOrDefault(); + if (first is null) + return null; + + return new() + { + Url = first + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs new file mode 100644 index 0000000..3ac59f8 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YtdlYoutubeSearchService : YoutubedlxServiceBase, IEService +{ + public override async Task SearchAsync(string query) + => await InternalGetInfoAsync(query, false); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs new file mode 100644 index 0000000..d7e66fa --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YtdlpYoutubeSearchService : YoutubedlxServiceBase, IEService +{ + public override async Task SearchAsync(string query) + => await InternalGetInfoAsync(query, true); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs new file mode 100644 index 0000000..6239bdd --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs @@ -0,0 +1,34 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public abstract class YoutubedlxServiceBase : IYoutubeSearchService +{ + private YtdlOperation CreateYtdlOp(bool isYtDlp) + => new YtdlOperation("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "--get-id " + + "--no-check-certificate " + + "--default-search " + + "\"ytsearch:\" -- \"{0}\"", + isYtDlp: isYtDlp); + + protected async Task InternalGetInfoAsync(string query, bool isYtDlp) + { + var op = CreateYtdlOp(isYtDlp); + var data = await op.GetDataAsync(query); + var items = data?.Split('\n'); + if (items is null or { Length: 0 }) + return null; + + var id = items.FirstOrDefault(x => x.Length is > 5 and < 15); + if (id is null) + return null; + + return new VideoInfo() + { + Url = $"https://youtube.com/watch?v={id}" + }; + } + + public abstract Task SearchAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Searches.cs b/src/EllieBot/Modules/Searches/Searches.cs new file mode 100644 index 0000000..04050e6 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Searches.cs @@ -0,0 +1,600 @@ +#nullable disable +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Modules.Searches.Common; +using EllieBot.Modules.Searches.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Json; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Searches; + +public partial class Searches : EllieModule +{ + private static readonly ConcurrentDictionary _cachedShortenedLinks = new(); + private readonly IBotCredentials _creds; + private readonly IGoogleApiService _google; + private readonly IHttpClientFactory _httpFactory; + private readonly IMemoryCache _cache; + private readonly ITimezoneService _tzSvc; + + public Searches( + IBotCredentials creds, + IGoogleApiService google, + IHttpClientFactory factory, + IMemoryCache cache, + ITimezoneService tzSvc) + { + _creds = creds; + _google = google; + _httpFactory = factory; + _cache = cache; + _tzSvc = tzSvc; + } + + [Cmd] + public async Task Rip([Leftover] IGuildUser usr) + { + var av = usr.RealAvatarUrl(); + await using var picStream = await _service.GetRipPictureAsync(usr.Nickname ?? usr.Username, av); + await ctx.Channel.SendFileAsync(picStream, + "rip.png", + $"Rip {Format.Bold(usr.ToString())} \n\t- " + Format.Italics(ctx.User.ToString())); + } + + [Cmd] + public async Task Weather([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + var embed = _sender.CreateEmbed(); + var data = await _service.GetWeatherDataAsync(query); + + if (data is null) + embed.WithDescription(GetText(strs.city_not_found)).WithErrorColor(); + else + { + var f = StandardConversions.CelsiusToFahrenheit; + + var tz = _tzSvc.GetTimeZoneOrUtc(ctx.Guild?.Id); + var sunrise = data.Sys.Sunrise.ToUnixTimestamp(); + var sunset = data.Sys.Sunset.ToUnixTimestamp(); + sunrise = sunrise.ToOffset(tz.GetUtcOffset(sunrise)); + sunset = sunset.ToOffset(tz.GetUtcOffset(sunset)); + var timezone = $"UTC{sunrise:zzz}"; + + embed + .AddField("🌍 " + Format.Bold(GetText(strs.location)), + $"[{data.Name + ", " + data.Sys.Country}](https://openweathermap.org/city/{data.Id})", + true) + .AddField("📏 " + Format.Bold(GetText(strs.latlong)), $"{data.Coord.Lat}, {data.Coord.Lon}", true) + .AddField("☁ " + Format.Bold(GetText(strs.condition)), + string.Join(", ", data.Weather.Select(w => w.Main)), + true) + .AddField("😓 " + Format.Bold(GetText(strs.humidity)), $"{data.Main.Humidity}%", true) + .AddField("💨 " + Format.Bold(GetText(strs.wind_speed)), data.Wind.Speed + " m/s", true) + .AddField("🌡 " + Format.Bold(GetText(strs.temperature)), + $"{data.Main.Temp:F1}°C / {f(data.Main.Temp):F1}°F", + true) + .AddField("🔆 " + Format.Bold(GetText(strs.min_max)), + $"{data.Main.TempMin:F1}°C - {data.Main.TempMax:F1}°C\n{f(data.Main.TempMin):F1}°F - {f(data.Main.TempMax):F1}°F", + true) + .AddField("🌄 " + Format.Bold(GetText(strs.sunrise)), $"{sunrise:HH:mm} {timezone}", true) + .AddField("🌇 " + Format.Bold(GetText(strs.sunset)), $"{sunset:HH:mm} {timezone}", true) + .WithOkColor() + .WithFooter("Powered by openweathermap.org", + $"https://openweathermap.org/img/w/{data.Weather[0].Icon}.png"); + } + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Time([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var (data, err) = await _service.GetTimeDataAsync(query); + if (err is not null) + { + LocStr errorKey; + switch (err) + { + case TimeErrors.ApiKeyMissing: + errorKey = strs.api_key_missing; + break; + case TimeErrors.InvalidInput: + errorKey = strs.invalid_input; + break; + case TimeErrors.NotFound: + errorKey = strs.not_found; + break; + default: + errorKey = strs.error_occured; + break; + } + + await Response().Error(errorKey).SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(data.TimeZoneName)) + { + await Response().Error(strs.timezone_db_api_key).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.time_new)) + .WithDescription(Format.Code(data.Time.ToString(Culture))) + .AddField(GetText(strs.location), string.Join('\n', data.Address.Split(", ")), true) + .AddField(GetText(strs.timezone), data.TimeZoneName, true); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + public async Task Movie([Leftover] string query = null) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var movie = await _service.GetMovieDataAsync(query); + if (movie is null) + { + await Response().Error(strs.imdb_fail).SendAsync(); + return; + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(movie.Title) + .WithUrl($"https://www.imdb.com/title/{movie.ImdbId}/") + .WithDescription(movie.Plot.TrimTo(1000)) + .AddField("Rating", movie.ImdbRating, true) + .AddField("Genre", movie.Genre, true) + .AddField("Year", movie.Year, true) + .WithImageUrl(movie.Poster)) + .SendAsync(); + } + + [Cmd] + public Task RandomCat() + => InternalRandomImage(SearchesService.ImageTag.Cats); + + [Cmd] + public Task RandomDog() + => InternalRandomImage(SearchesService.ImageTag.Dogs); + + [Cmd] + public Task RandomFood() + => InternalRandomImage(SearchesService.ImageTag.Food); + + [Cmd] + public Task RandomBird() + => InternalRandomImage(SearchesService.ImageTag.Birds); + + private Task InternalRandomImage(SearchesService.ImageTag tag) + { + var url = _service.GetRandomImageUrl(tag); + return Response().Embed(_sender.CreateEmbed().WithOkColor().WithImageUrl(url)).SendAsync(); + } + + [Cmd] + public async Task Lmgtfy([Leftover] string ffs = null) + { + if (!await ValidateQuery(ffs)) + return; + + var shortenedUrl = await _google.ShortenUrl($"https://letmegooglethat.com/?q={Uri.EscapeDataString(ffs)}"); + await Response().Confirm($"<{shortenedUrl}>").SendAsync(); + } + + [Cmd] + public async Task Shorten([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + query = query.Trim(); + if (!_cachedShortenedLinks.TryGetValue(query, out var shortLink)) + { + try + { + using var http = _httpFactory.CreateClient(); + using var req = new HttpRequestMessage(HttpMethod.Post, "https://goolnk.com/api/v1/shorten"); + var formData = new MultipartFormDataContent + { + { new StringContent(query), "url" } + }; + req.Content = formData; + + using var res = await http.SendAsync(req); + var content = await res.Content.ReadAsStringAsync(); + var data = JsonConvert.DeserializeObject(content); + + if (!string.IsNullOrWhiteSpace(data?.ResultUrl)) + _cachedShortenedLinks.TryAdd(query, data.ResultUrl); + else + return; + + shortLink = data.ResultUrl; + } + catch (Exception ex) + { + Log.Error(ex, "Error shortening a link: {Message}", ex.Message); + return; + } + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .AddField(GetText(strs.original_url), $"<{query}>") + .AddField(GetText(strs.short_url), $"<{shortLink}>")) + .SendAsync(); + } + + [Cmd] + public async Task MagicTheGathering([Leftover] string search) + { + if (!await ValidateQuery(search)) + return; + + await ctx.Channel.TriggerTypingAsync(); + var card = await _service.GetMtgCardAsync(search); + + if (card is null) + { + await Response().Error(strs.card_not_found).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(card.Name) + .WithDescription(card.Description) + .WithImageUrl(card.ImageUrl) + .AddField(GetText(strs.store_url), card.StoreUrl, true) + .AddField(GetText(strs.cost), card.ManaCost, true) + .AddField(GetText(strs.types), card.Types, true); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Hearthstone([Leftover] string name) + { + if (!await ValidateQuery(name)) + return; + + if (string.IsNullOrWhiteSpace(_creds.RapidApiKey)) + { + await Response().Error(strs.mashape_api_missing).SendAsync(); + return; + } + + await ctx.Channel.TriggerTypingAsync(); + var card = await _service.GetHearthstoneCardDataAsync(name); + + if (card is null) + { + await Response().Error(strs.card_not_found).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor().WithImageUrl(card.Img); + + if (!string.IsNullOrWhiteSpace(card.Flavor)) + embed.WithDescription(card.Flavor); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task UrbanDict([Leftover] string query = null) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + using (var http = _httpFactory.CreateClient()) + { + var res = await http.GetStringAsync( + $"https://api.urbandictionary.com/v0/define?term={Uri.EscapeDataString(query)}"); + try + { + var allItems = JsonConvert.DeserializeObject(res).List; + if (allItems.Any()) + { + await Response() + .Paginated() + .Items(allItems) + .PageSize(1) + .CurrentPage(0) + .Page((items, _) => + { + var item = items[0]; + return _sender.CreateEmbed() + .WithOkColor() + .WithUrl(item.Permalink) + .WithTitle(item.Word) + .WithDescription(item.Definition); + }) + .SendAsync(); + return; + } + } + catch + { + } + } + + await Response().Error(strs.ud_error).SendAsync(); + } + + [Cmd] + public async Task Define([Leftover] string word) + { + if (!await ValidateQuery(word)) + return; + + using var http = _httpFactory.CreateClient(); + string res; + try + { + res = await _cache.GetOrCreateAsync($"define_{word}", + e => + { + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12); + return http.GetStringAsync("https://api.pearson.com/v2/dictionaries/entries?headword=" + + WebUtility.UrlEncode(word)); + }); + + var responseModel = JsonConvert.DeserializeObject(res); + + var data = responseModel.Results + .Where(x => x.Senses is not null + && x.Senses.Count > 0 + && x.Senses[0].Definition is not null) + .Select(x => (Sense: x.Senses[0], x.PartOfSpeech)) + .ToList(); + + if (!data.Any()) + { + Log.Warning("Definition not found: {Word}", word); + await Response().Error(strs.define_unknown).SendAsync(); + } + + + var col = data.Select(x => ( + Definition: x.Sense.Definition is string + ? x.Sense.Definition.ToString() + : ((JArray)JToken.Parse(x.Sense.Definition.ToString())).First.ToString(), + Example: x.Sense.Examples is null || x.Sense.Examples.Count == 0 + ? string.Empty + : x.Sense.Examples[0].Text, Word: word, + WordType: string.IsNullOrWhiteSpace(x.PartOfSpeech) ? "-" : x.PartOfSpeech)) + .ToList(); + + Log.Information("Sending {Count} definition for: {Word}", col.Count, word); + + await Response() + .Paginated() + .Items(col) + .PageSize(1) + .Page((items, _) => + { + var model = items.First(); + var embed = _sender.CreateEmbed() + .WithDescription(ctx.User.Mention) + .AddField(GetText(strs.word), model.Word, true) + .AddField(GetText(strs._class), model.WordType, true) + .AddField(GetText(strs.definition), model.Definition) + .WithOkColor(); + + if (!string.IsNullOrWhiteSpace(model.Example)) + embed.AddField(GetText(strs.example), model.Example); + + return embed; + }) + .SendAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Error retrieving definition data for: {Word}", word); + } + } + + [Cmd] + public async Task Catfact() + { + using var http = _httpFactory.CreateClient(); + var response = await http.GetStringAsync("https://catfact.ninja/fact"); + + var fact = JObject.Parse(response)["fact"].ToString(); + await Response().Confirm("🐈" + GetText(strs.catfact), fact).SendAsync(); + } + + [Cmd] + public async Task Wiki([Leftover] string query = null) + { + query = query?.Trim(); + + if (!await ValidateQuery(query)) + return; + + using var http = _httpFactory.CreateClient(); + var result = await http.GetStringAsync( + "https://en.wikipedia.org//w/api.php?action=query&format=json&prop=info&redirects=1&formatversion=2&inprop=url&titles=" + + Uri.EscapeDataString(query)); + var data = JsonConvert.DeserializeObject(result); + if (data.Query.Pages[0].Missing || string.IsNullOrWhiteSpace(data.Query.Pages[0].FullUrl)) + await Response().Error(strs.wiki_page_not_found).SendAsync(); + else + await Response().Text(data.Query.Pages[0].FullUrl).SendAsync(); + } + + [Cmd] + public async Task Color(params Color[] colors) + { + if (!colors.Any()) + return; + + var colorObjects = colors.Take(10).ToArray(); + + using var img = new Image(colorObjects.Length * 50, 50); + for (var i = 0; i < colorObjects.Length; i++) + { + var x = i * 50; + img.Mutate(m => m.FillPolygon(colorObjects[i], new(x, 0), new(x + 50, 0), new(x + 50, 50), new(x, 50))); + } + + await using var ms = img.ToStream(); + await ctx.Channel.SendFileAsync(ms, "colors.png"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Avatar([Leftover] IGuildUser usr = null) + { + if (usr is null) + usr = (IGuildUser)ctx.User; + + var avatarUrl = usr.RealAvatarUrl(2048); + + await Response() + .Embed( + _sender.CreateEmbed() + .WithOkColor() + .AddField("Username", usr.ToString()) + .AddField("Avatar Url", avatarUrl) + .WithThumbnailUrl(avatarUrl.ToString())) + .SendAsync(); + } + + [Cmd] + public async Task Wikia(string target, [Leftover] string query) + { + if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.wikia_input_error).SendAsync(); + return; + } + + await ctx.Channel.TriggerTypingAsync(); + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + try + { + var res = await http.GetStringAsync($"https://{Uri.EscapeDataString(target)}.fandom.com/api.php" + + "?action=query" + + "&format=json" + + "&list=search" + + $"&srsearch={Uri.EscapeDataString(query)}" + + "&srlimit=1"); + var items = JObject.Parse(res); + var title = items["query"]?["search"]?.FirstOrDefault()?["title"]?.ToString(); + + if (string.IsNullOrWhiteSpace(title)) + { + await Response().Error(strs.wikia_error).SendAsync(); + return; + } + + var url = Uri.EscapeDataString($"https://{target}.fandom.com/wiki/{title}"); + var response = $@"`{GetText(strs.title)}` {title.SanitizeMentions()} +`{GetText(strs.url)}:` {url}"; + await Response().Text(response).SendAsync(); + } + catch + { + await Response().Error(strs.wikia_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Bible(string book, string chapterAndVerse) + { + var obj = new BibleVerses(); + try + { + using var http = _httpFactory.CreateClient(); + obj = await http.GetFromJsonAsync($"https://bible-api.com/{book} {chapterAndVerse}"); + } + catch + { + } + + if (obj.Error is not null || obj.Verses is null || obj.Verses.Length == 0) + await Response().Error(obj.Error ?? "No verse found.").SendAsync(); + else + { + var v = obj.Verses[0]; + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle($"{v.BookName} {v.Chapter}:{v.Verse}") + .WithDescription(v.Text)) + .SendAsync(); + } + } + + [Cmd] + public async Task Steam([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var appId = await _service.GetSteamAppIdByName(query); + if (appId == -1) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + //var embed = _sender.CreateEmbed() + // .WithOkColor() + // .WithDescription(gameData.ShortDescription) + // .WithTitle(gameData.Name) + // .WithUrl(gameData.Link) + // .WithImageUrl(gameData.HeaderImage) + // .AddField(GetText(strs.genres), gameData.TotalEpisodes.ToString(), true) + // .AddField(GetText(strs.price), gameData.IsFree ? GetText(strs.FREE) : game, true) + // .AddField(GetText(strs.links), gameData.GetGenresString(), true) + // .WithFooter(GetText(strs.recommendations(gameData.TotalRecommendations))); + await Response().Text($"https://store.steampowered.com/app/{appId}").SendAsync(); + } + + private async Task ValidateQuery([MaybeNullWhen(false)] string query) + { + if (!string.IsNullOrWhiteSpace(query)) + return true; + + await Response().Error(strs.specify_search_params).SendAsync(); + return false; + } + + public class ShortenData + { + [JsonProperty("result_url")] + public string ResultUrl { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/SearchesService.cs b/src/EllieBot/Modules/Searches/SearchesService.cs new file mode 100644 index 0000000..f5e3be4 --- /dev/null +++ b/src/EllieBot/Modules/Searches/SearchesService.cs @@ -0,0 +1,457 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Color = SixLabors.ImageSharp.Color; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Searches.Services; + +public class SearchesService : IEService +{ + public enum ImageTag + { + Food, + Dogs, + Cats, + Birds + } + + public List WowJokes { get; } = []; + public List MagicItems { get; } = []; + private readonly IHttpClientFactory _httpFactory; + private readonly IGoogleApiService _google; + private readonly IImageCache _imgs; + private readonly IBotCache _c; + private readonly FontProvider _fonts; + private readonly IBotCredsProvider _creds; + private readonly EllieRandom _rng; + private readonly List _yomamaJokes; + + private readonly object _yomamaLock = new(); + private int yomamaJokeIndex; + + public SearchesService( + IGoogleApiService google, + IImageCache images, + IBotCache c, + IHttpClientFactory factory, + FontProvider fonts, + IBotCredsProvider creds) + { + _httpFactory = factory; + _google = google; + _imgs = images; + _c = c; + _fonts = fonts; + _creds = creds; + _rng = new(); + + //joke commands + if (File.Exists("data/wowjokes.json")) + WowJokes = JsonConvert.DeserializeObject>(File.ReadAllText("data/wowjokes.json")); + else + Log.Warning("data/wowjokes.json is missing. WOW Jokes are not loaded"); + + if (File.Exists("data/magicitems.json")) + MagicItems = JsonConvert.DeserializeObject>(File.ReadAllText("data/magicitems.json")); + else + Log.Warning("data/magicitems.json is missing. Magic items are not loaded"); + + if (File.Exists("data/yomama.txt")) + _yomamaJokes = File.ReadAllLines("data/yomama.txt").Shuffle().ToList(); + else + { + _yomamaJokes = []; + Log.Warning("data/yomama.txt is missing. .yomama command won't work"); + } + } + + public async Task GetRipPictureAsync(string text, Uri imgUrl) + => (await GetRipPictureFactory(text, imgUrl)).ToStream(); + + private void DrawAvatar(Image bg, Image avatarImage) + => bg.Mutate(x => x.Grayscale().DrawImage(avatarImage, new(83, 139), new GraphicsOptions())); + + public async Task GetRipPictureFactory(string text, Uri avatarUrl) + { + using var bg = Image.Load(await _imgs.GetRipBgAsync()); + var result = await _c.GetImageDataAsync(avatarUrl); + if (!result.TryPickT0(out var data, out _)) + { + using var http = _httpFactory.CreateClient(); + data = await http.GetByteArrayAsync(avatarUrl); + using (var avatarImg = Image.Load(data)) + { + avatarImg.Mutate(x => x.Resize(85, 85).ApplyRoundedCorners(42)); + await using var avStream = await avatarImg.ToStreamAsync(); + data = avStream.ToArray(); + DrawAvatar(bg, avatarImg); + } + + await _c.SetImageDataAsync(avatarUrl, data); + } + else + { + using var avatarImg = Image.Load(data); + DrawAvatar(bg, avatarImg); + } + + bg.Mutate(x => x.DrawText( + new TextOptions(_fonts.RipFont) + { + HorizontalAlignment = HorizontalAlignment.Center, + FallbackFontFamilies = _fonts.FallBackFonts, + Origin = new(bg.Width / 2, 225), + }, + text, + Color.Black)); + + //flowa + using (var flowers = Image.Load(await _imgs.GetRipOverlayAsync())) + { + bg.Mutate(x => x.DrawImage(flowers, new(0, 0), new GraphicsOptions())); + } + + await using var stream = bg.ToStream(); + return stream.ToArray(); + } + + public async Task GetWeatherDataAsync(string query) + { + query = query.Trim().ToLowerInvariant(); + + return await _c.GetOrAddAsync(new($"ellie_weather_{query}"), + async () => await GetWeatherDataFactory(query), + TimeSpan.FromHours(3)); + } + + private async Task GetWeatherDataFactory(string query) + { + using var http = _httpFactory.CreateClient(); + try + { + var data = await http.GetStringAsync("https://api.openweathermap.org/data/2.5/weather?" + + $"q={query}&" + + "appid=42cd627dd60debf25a5739e50a217d74&" + + "units=metric"); + + if (string.IsNullOrWhiteSpace(data)) + return null; + + return JsonConvert.DeserializeObject(data); + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting weather data"); + return null; + } + } + + public Task<((string Address, DateTime Time, string TimeZoneName), TimeErrors?)> GetTimeDataAsync(string arg) + => GetTimeDataFactory(arg); + + //return _cache.GetOrAddCachedDataAsync($"ellie_time_{arg}", + // GetTimeDataFactory, + // arg, + // TimeSpan.FromMinutes(1)); + private async Task<((string Address, DateTime Time, string TimeZoneName), TimeErrors?)> GetTimeDataFactory( + string query) + { + query = query.Trim(); + + if (string.IsNullOrEmpty(query)) + return (default, TimeErrors.InvalidInput); + + + var locIqKey = _creds.GetCreds().LocationIqApiKey; + var tzDbKey = _creds.GetCreds().TimezoneDbApiKey; + if (string.IsNullOrWhiteSpace(locIqKey) || string.IsNullOrWhiteSpace(tzDbKey)) + return (default, TimeErrors.ApiKeyMissing); + + try + { + using var http = _httpFactory.CreateClient(); + var res = await _c.GetOrAddAsync(new($"searches:geo:{query}"), + async () => + { + var url = "https://eu1.locationiq.com/v1/search.php?" + + (string.IsNullOrWhiteSpace(locIqKey) + ? "key=" + : $"key={locIqKey}&") + + $"q={Uri.EscapeDataString(query)}&" + + "format=json"; + + var res = await http.GetStringAsync(url); + return res; + }, + TimeSpan.FromHours(1)); + + var responses = JsonConvert.DeserializeObject(res); + if (responses is null || responses.Length == 0) + { + Log.Warning("Geocode lookup failed for: {Query}", query); + return (default, TimeErrors.NotFound); + } + + var geoData = responses[0]; + + using var req = new HttpRequestMessage(HttpMethod.Get, + "http://api.timezonedb.com/v2.1/get-time-zone?" + + $"key={tzDbKey}" + + $"&format=json" + + $"&by=position" + + $"&lat={geoData.Lat}" + + $"&lng={geoData.Lon}"); + + using var geoRes = await http.SendAsync(req); + var resString = await geoRes.Content.ReadAsStringAsync(); + var timeObj = JsonConvert.DeserializeObject(resString); + + var time = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(timeObj.Timestamp); + + return ((Address: responses[0].DisplayName, Time: time, TimeZoneName: timeObj.TimezoneName), default); + } + catch (Exception ex) + { + Log.Error(ex, "Weather error: {Message}", ex.Message); + return (default, TimeErrors.NotFound); + } + } + + public string GetRandomImageUrl(ImageTag tag) + { + var subpath = tag.ToString().ToLowerInvariant(); + + var max = tag switch + { + ImageTag.Food => 773, + ImageTag.Dogs => 750, + ImageTag.Cats => 773, + ImageTag.Birds => 578, + _ => 100, + }; + + + return $"https://nadeko-pictures.nyc3.digitaloceanspaces.com/{subpath}/" + + _rng.Next(1, max).ToString("000") + + ".png"; + } + + public Task GetYomamaJoke() + { + string joke; + lock (_yomamaLock) + { + if (yomamaJokeIndex >= _yomamaJokes.Count) + { + yomamaJokeIndex = 0; + var newList = _yomamaJokes.ToList(); + _yomamaJokes.Clear(); + _yomamaJokes.AddRange(newList.Shuffle()); + } + + joke = _yomamaJokes[yomamaJokeIndex++]; + } + + return Task.FromResult(joke); + + // using (var http = _httpFactory.CreateClient()) + // { + // var response = await http.GetStringAsync(new Uri("http://api.yomomma.info/")); + // return JObject.Parse(response)["joke"].ToString() + " 😆"; + // } + } + + public async Task<(string Setup, string Punchline)> GetRandomJoke() + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync("https://official-joke-api.appspot.com/random_joke"); + var resObj = JsonConvert.DeserializeAnonymousType(res, + new + { + setup = "", + punchline = "" + }); + return (resObj.setup, resObj.punchline); + } + + public async Task GetChuckNorrisJoke() + { + using var http = _httpFactory.CreateClient(); + var response = await http.GetStringAsync(new Uri("https://api.chucknorris.io/jokes/random")); + return JObject.Parse(response)["value"] + " 😆"; + } + + public async Task GetMtgCardAsync(string search) + { + search = search.Trim().ToLowerInvariant(); + var data = await _c.GetOrAddAsync(new($"mtg:{search}"), + async () => await GetMtgCardFactory(search), + TimeSpan.FromDays(1)); + + if (data is null || data.Length == 0) + return null; + + return data[_rng.Next(0, data.Length)]; + } + + private async Task GetMtgCardFactory(string search) + { + async Task GetMtgDataAsync(MtgResponse.Data card) + { + string storeUrl; + try + { + storeUrl = await _google.ShortenUrl("https://shop.tcgplayer.com/productcatalog/product/show?" + + "newSearch=false&" + + "ProductType=All&" + + "IsProductNameExact=false&" + + $"ProductName={Uri.EscapeDataString(card.Name)}"); + } + catch { storeUrl = ""; } + + return new() + { + Description = card.Text, + Name = card.Name, + ImageUrl = card.ImageUrl, + StoreUrl = storeUrl, + Types = string.Join(",\n", card.Types), + ManaCost = card.ManaCost + }; + } + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + var response = + await http.GetStringAsync($"https://api.magicthegathering.io/v1/cards?name={Uri.EscapeDataString(search)}"); + + var responseObject = JsonConvert.DeserializeObject(response); + if (responseObject is null) + return Array.Empty(); + + var cards = responseObject.Cards.Take(5).ToArray(); + if (cards.Length == 0) + return Array.Empty(); + + return await cards.Select(GetMtgDataAsync).WhenAll(); + } + + public async Task GetHearthstoneCardDataAsync(string name) + { + name = name.ToLowerInvariant(); + return await _c.GetOrAddAsync($"hearthstone:{name}", + () => HearthstoneCardDataFactory(name), + TimeSpan.FromDays(1)); + } + + private async Task HearthstoneCardDataFactory(string name) + { + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.GetCreds().RapidApiKey); + try + { + var response = await http.GetStringAsync("https://omgvamp-hearthstone-v1.p.rapidapi.com/" + + $"cards/search/{Uri.EscapeDataString(name)}"); + var objs = JsonConvert.DeserializeObject(response); + if (objs is null || objs.Length == 0) + return null; + var data = objs.FirstOrDefault(x => x.Collectible) + ?? objs.FirstOrDefault(x => !string.IsNullOrEmpty(x.PlayerClass)) ?? objs.FirstOrDefault(); + if (data is null) + return null; + if (!string.IsNullOrWhiteSpace(data.Img)) + data.Img = await _google.ShortenUrl(data.Img); + // if (!string.IsNullOrWhiteSpace(data.Text)) + // { + // var converter = new Converter(); + // data.Text = converter.Convert(data.Text); + // } + + return data; + } + catch (Exception ex) + { + Log.Error(ex, "Error getting Hearthstone Card: {ErrorMessage}", ex.Message); + return null; + } + } + + public async Task GetMovieDataAsync(string name) + { + name = name.Trim().ToLowerInvariant(); + return await _c.GetOrAddAsync(new($"movie:{name}"), + () => GetMovieDataFactory(name), + TimeSpan.FromDays(1)); + } + + private async Task GetMovieDataFactory(string name) + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync(string.Format("https://omdbapi.nadeko.bot/" + + "?t={0}" + + "&y=" + + "&plot=full" + + "&r=json", + name.Trim().Replace(' ', '+'))); + var movie = JsonConvert.DeserializeObject(res); + if (movie?.Title is null) + return null; + movie.Poster = await _google.ShortenUrl(movie.Poster); + return movie; + } + + public async Task GetSteamAppIdByName(string query) + { + const string steamGameIdsKey = "steam_names_to_appid"; + + var gamesMap = await _c.GetOrAddAsync(new(steamGameIdsKey), + async () => + { + using var http = _httpFactory.CreateClient(); + + // https://api.steampowered.com/ISteamApps/GetAppList/v2/ + var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/"); + var apps = JsonConvert + .DeserializeAnonymousType(gamesStr, + new + { + applist = new + { + apps = new List() + } + })! + .applist.apps; + + return apps.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .GroupBy(x => x.Name) + .ToDictionary(x => x.Key, x => x.First().AppId); + }, + TimeSpan.FromHours(24)); + + if (gamesMap is null) + return -1; + + query = query.Trim(); + + var keyList = gamesMap.Keys.ToList(); + + var key = keyList.FirstOrDefault(x => x.Equals(query, StringComparison.OrdinalIgnoreCase)); + + if (key == default) + { + key = keyList.FirstOrDefault(x => x.StartsWith(query, StringComparison.OrdinalIgnoreCase)); + if (key == default) + return -1; + } + + return gamesMap[key]; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs new file mode 100644 index 0000000..46f9ed6 --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs @@ -0,0 +1,211 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class StreamNotificationCommands : EllieModule + { + private readonly DbService _db; + + public StreamNotificationCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamAdd(string link) + { + var data = await _service.FollowStream(ctx.Guild.Id, ctx.Channel.Id, link); + if (data is null) + { + await Response().Error(strs.stream_not_added).SendAsync(); + return; + } + + var embed = _service.GetEmbed(ctx.Guild.Id, data); + await Response() + .Embed(embed) + .Text(strs.stream_tracked) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task StreamRemove(int index) + { + if (--index < 0) + return; + + var fs = await _service.UnfollowStreamAsync(ctx.Guild.Id, index); + if (fs is null) + { + await Response().Error(strs.stream_no).SendAsync(); + return; + } + + await Response().Confirm(strs.stream_removed(Format.Bold(fs.Username), fs.Type)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task StreamsClear() + { + await _service.ClearAllStreams(ctx.Guild.Id); + await Response().Confirm(strs.streams_cleared).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task StreamList(int page = 1) + { + if (page-- < 1) + return; + + var allStreams = new List(); + await using (var uow = _db.GetDbContext()) + { + var all = uow.GuildConfigsForId(ctx.Guild.Id, set => set.Include(gc => gc.FollowedStreams)) + .FollowedStreams.OrderBy(x => x.Id) + .ToList(); + + for (var index = all.Count - 1; index >= 0; index--) + { + var fs = all[index]; + if (((SocketGuild)ctx.Guild).GetTextChannel(fs.ChannelId) is null) + await _service.UnfollowStreamAsync(fs.GuildId, index); + else + allStreams.Insert(0, fs); + } + } + + await Response() + .Paginated() + .Items(allStreams) + .PageSize(12) + .CurrentPage(page) + .Page((elements, cur) => + { + if (elements.Count == 0) + return _sender.CreateEmbed().WithDescription(GetText(strs.streams_none)).WithErrorColor(); + + var eb = _sender.CreateEmbed().WithTitle(GetText(strs.streams_follow_title)).WithOkColor(); + for (var index = 0; index < elements.Count; index++) + { + var elem = elements[index]; + eb.AddField($"**#{index + 1 + (12 * cur)}** {elem.Username.ToLower()}", + $"【{elem.Type}】\n<#{elem.ChannelId}>\n{elem.Message?.TrimTo(50)}", + true); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamOffline() + { + var newValue = _service.ToggleStreamOffline(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.stream_off_enabled).SendAsync(); + else + await Response().Confirm(strs.stream_off_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamOnlineDelete() + { + var newValue = _service.ToggleStreamOnlineDelete(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.stream_online_delete_enabled).SendAsync(); + else + await Response().Confirm(strs.stream_online_delete_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamMessage(int index, [Leftover] string message) + { + if (--index < 0) + return; + + var canMentionEveryone = (ctx.User as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + if (!canMentionEveryone) + message = message?.SanitizeAllMentions(); + + if (!_service.SetStreamMessage(ctx.Guild.Id, index, message, out var fs)) + { + await Response().Confirm(strs.stream_not_following).SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(message)) + await Response().Confirm(strs.stream_message_reset(Format.Bold(fs.Username))).SendAsync(); + else + await Response().Confirm(strs.stream_message_set(Format.Bold(fs.Username))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamMessageAll([Leftover] string message) + { + var canMentionEveryone = (ctx.User as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + if (!canMentionEveryone) + message = message?.SanitizeAllMentions(); + + var count = _service.SetStreamMessageForAll(ctx.Guild.Id, message); + + if (count == 0) + { + await Response().Confirm(strs.stream_not_following_any).SendAsync(); + return; + } + + await Response().Confirm(strs.stream_message_set_all(count)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task StreamCheck(string url) + { + try + { + var data = await _service.GetStreamDataAsync(url); + if (data is null) + { + await Response().Error(strs.no_channel_found).SendAsync(); + return; + } + + if (data.IsLive) + { + await Response() + .Confirm(strs.streamer_online(Format.Bold(data.Name), + Format.Bold(data.Viewers.ToString()))) + .SendAsync(); + } + else + await Response().Confirm(strs.streamer_offline(data.Name)).SendAsync(); + } + catch + { + await Response().Error(strs.no_channel_found).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs new file mode 100644 index 0000000..c003366 --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs @@ -0,0 +1,651 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Common; +using EllieBot.Modules.Searches.Common.StreamNotifications; + +namespace EllieBot.Modules.Searches.Services; + +public sealed class StreamNotificationService : IEService, IReadyExecutor +{ + private readonly DbService _db; + private readonly IBotStrings _strings; + private readonly Random _rng = new EllieRandom(); + private readonly DiscordSocketClient _client; + private readonly NotifChecker _streamTracker; + + private readonly object _shardLock = new(); + + private readonly Dictionary> _trackCounter = new(); + + private readonly Dictionary>> _shardTrackedStreams; + private readonly ConcurrentHashSet _offlineNotificationServers; + private readonly ConcurrentHashSet _deleteOnOfflineServers; + + private readonly IPubSub _pubSub; + private readonly IMessageSenderService _sender; + private readonly SearchesConfigService _config; + private readonly IReplacementService _repSvc; + + public TypedKey> StreamsOnlineKey { get; } + public TypedKey> StreamsOfflineKey { get; } + + private readonly TypedKey _streamFollowKey; + private readonly TypedKey _streamUnfollowKey; + + public event Func< + FollowedStream.FType, + string, + IReadOnlyCollection<(ulong, ulong)>, + Task> OnlineMessagesSent = static delegate { return Task.CompletedTask; }; + + public StreamNotificationService( + DbService db, + DiscordSocketClient client, + IBotStrings strings, + IBotCredsProvider creds, + IHttpClientFactory httpFactory, + IBot bot, + IPubSub pubSub, + IMessageSenderService sender, + SearchesConfigService config, + IReplacementService repSvc) + { + _db = db; + _client = client; + _strings = strings; + _pubSub = pubSub; + _sender = sender; + _config = config; + _repSvc = repSvc; + + _streamTracker = new(httpFactory, creds); + + StreamsOnlineKey = new("streams.online"); + StreamsOfflineKey = new("streams.offline"); + + _streamFollowKey = new("stream.follow"); + _streamUnfollowKey = new("stream.unfollow"); + + using (var uow = db.GetDbContext()) + { + var ids = client.GetGuildIds(); + var guildConfigs = uow.Set() + .AsQueryable() + .Include(x => x.FollowedStreams) + .Where(x => ids.Contains(x.GuildId)) + .ToList(); + + _offlineNotificationServers = new(guildConfigs + .Where(gc => gc.NotifyStreamOffline) + .Select(x => x.GuildId) + .ToList()); + + _deleteOnOfflineServers = new(guildConfigs + .Where(gc => gc.DeleteStreamOnlineMessage) + .Select(x => x.GuildId) + .ToList()); + + var followedStreams = guildConfigs.SelectMany(x => x.FollowedStreams).ToList(); + + _shardTrackedStreams = followedStreams.GroupBy(x => new + { + x.Type, + Name = x.Username.ToLower() + }) + .ToList() + .ToDictionary( + x => new StreamDataKey(x.Key.Type, x.Key.Name.ToLower()), + x => x.GroupBy(y => y.GuildId) + .ToDictionary(y => y.Key, + y => y.AsEnumerable().ToHashSet())); + + // shard 0 will keep track of when there are no more guilds which track a stream + if (client.ShardId == 0) + { + var allFollowedStreams = uow.Set().AsQueryable().ToList(); + + foreach (var fs in allFollowedStreams) + _streamTracker.AddLastData(fs.CreateKey(), null, false); + + _trackCounter = allFollowedStreams.GroupBy(x => new + { + x.Type, + Name = x.Username.ToLower() + }) + .ToDictionary(x => new StreamDataKey(x.Key.Type, x.Key.Name), + x => x.Select(fs => fs.GuildId).ToHashSet()); + } + } + + _pubSub.Sub(StreamsOfflineKey, HandleStreamsOffline); + _pubSub.Sub(StreamsOnlineKey, HandleStreamsOnline); + + if (client.ShardId == 0) + { + // only shard 0 will run the tracker, + // and then publish updates with redis to other shards + _streamTracker.OnStreamsOffline += OnStreamsOffline; + _streamTracker.OnStreamsOnline += OnStreamsOnline; + _ = _streamTracker.RunAsync(); + + _pubSub.Sub(_streamFollowKey, HandleFollowStream); + _pubSub.Sub(_streamUnfollowKey, HandleUnfollowStream); + } + + bot.JoinedGuild += ClientOnJoinedGuild; + client.LeftGuild += ClientOnLeftGuild; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var errorLimit = TimeSpan.FromHours(12); + var failingStreams = _streamTracker.GetFailingStreams(errorLimit, true).ToList(); + + if (!failingStreams.Any()) + continue; + + var deleteGroups = failingStreams.GroupBy(x => x.Type) + .ToDictionary(x => x.Key, x => x.Select(y => y.Name).ToList()); + + await using var uow = _db.GetDbContext(); + foreach (var kvp in deleteGroups) + { + Log.Information( + "Deleting {StreamCount} {Platform} streams because they've been erroring for more than {ErrorLimit}: {RemovedList}", + kvp.Value.Count, + kvp.Key, + errorLimit, + string.Join(", ", kvp.Value)); + + var toDelete = uow.Set() + .AsQueryable() + .Where(x => x.Type == kvp.Key && kvp.Value.Contains(x.Username)) + .ToList(); + + uow.RemoveRange(toDelete); + await uow.SaveChangesAsync(); + + foreach (var loginToDelete in kvp.Value) + _streamTracker.UntrackStreamByKey(new(kvp.Key, loginToDelete)); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error cleaning up FollowedStreams"); + } + } + } + + /// + /// Handles follow stream pubs to keep the counter up to date. + /// When counter reaches 0, stream is removed from tracking because + /// that means no guilds are subscribed to that stream anymore + /// + private ValueTask HandleFollowStream(FollowStreamPubData info) + { + _streamTracker.AddLastData(info.Key, null, false); + lock (_shardLock) + { + var key = info.Key; + if (_trackCounter.ContainsKey(key)) + _trackCounter[key].Add(info.GuildId); + else + { + _trackCounter[key] = [info.GuildId]; + } + } + + return default; + } + + /// + /// Handles unfollow pubs to keep the counter up to date. + /// When counter reaches 0, stream is removed from tracking because + /// that means no guilds are subscribed to that stream anymore + /// + private ValueTask HandleUnfollowStream(FollowStreamPubData info) + { + lock (_shardLock) + { + var key = info.Key; + if (!_trackCounter.TryGetValue(key, out var set)) + { + // it should've been removed already? + _streamTracker.UntrackStreamByKey(in key); + return default; + } + + set.Remove(info.GuildId); + if (set.Count != 0) + return default; + + _trackCounter.Remove(key); + // if no other guilds are following this stream + // untrack the stream + _streamTracker.UntrackStreamByKey(in key); + } + + return default; + } + + private async ValueTask HandleStreamsOffline(List offlineStreams) + { + foreach (var stream in offlineStreams) + { + var key = stream.CreateKey(); + if (_shardTrackedStreams.TryGetValue(key, out var fss)) + { + await fss + // send offline stream notifications only to guilds which enable it with .stoff + .SelectMany(x => x.Value) + .Where(x => _offlineNotificationServers.Contains(x.GuildId)) + .Select(fs => + { + var ch = _client.GetGuild(fs.GuildId) + ?.GetTextChannel(fs.ChannelId); + + if (ch is null) + return Task.CompletedTask; + + return _sender.Response(ch).Embed(GetEmbed(fs.GuildId, stream)).SendAsync(); + }) + .WhenAll(); + } + } + } + + + private async ValueTask HandleStreamsOnline(List onlineStreams) + { + foreach (var stream in onlineStreams) + { + var key = stream.CreateKey(); + if (_shardTrackedStreams.TryGetValue(key, out var fss)) + { + var messages = await fss.SelectMany(x => x.Value) + .Select(async fs => + { + var textChannel = _client.GetGuild(fs.GuildId) + ?.GetTextChannel(fs.ChannelId); + + if (textChannel is null) + return default; + + var repCtx = new ReplacementContext(guild: textChannel.Guild, + client: _client) + .WithOverride("%platform%", () => fs.Type.ToString()); + + + var message = string.IsNullOrWhiteSpace(fs.Message) + ? "" + : await _repSvc.ReplaceAsync(fs.Message, repCtx); + + var msg = await _sender.Response(textChannel) + .Embed(GetEmbed(fs.GuildId, stream, false)) + .Text(message) + .Sanitize(false) + .SendAsync(); + + // only cache the ids of channel/message pairs + if (_deleteOnOfflineServers.Contains(fs.GuildId)) + return (textChannel.Id, msg.Id); + else + return default; + }) + .WhenAll(); + + + // push online stream messages to redis + // when streams go offline, any server which + // has the online stream message deletion feature + // enabled will have the online messages deleted + try + { + var pairs = messages + .Where(x => x != default) + .Select(x => (x.Item1, x.Item2)) + .ToList(); + + if (pairs.Count > 0) + await OnlineMessagesSent(key.Type, key.Name, pairs); + } + catch + { + } + } + } + } + + private Task OnStreamsOnline(List data) + => _pubSub.Pub(StreamsOnlineKey, data); + + private Task OnStreamsOffline(List data) + => _pubSub.Pub(StreamsOfflineKey, data); + + private Task ClientOnJoinedGuild(GuildConfig guildConfig) + { + using (var uow = _db.GetDbContext()) + { + var gc = uow.Set() + .AsQueryable() + .Include(x => x.FollowedStreams) + .FirstOrDefault(x => x.GuildId == guildConfig.GuildId); + + if (gc is null) + return Task.CompletedTask; + + if (gc.NotifyStreamOffline) + _offlineNotificationServers.Add(gc.GuildId); + + foreach (var followedStream in gc.FollowedStreams) + { + var key = followedStream.CreateKey(); + var streams = GetLocalGuildStreams(key, gc.GuildId); + streams.Add(followedStream); + PublishFollowStream(followedStream); + } + } + + return Task.CompletedTask; + } + + private Task ClientOnLeftGuild(SocketGuild guild) + { + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.FollowedStreams)); + + _offlineNotificationServers.TryRemove(gc.GuildId); + + foreach (var followedStream in gc.FollowedStreams) + { + var streams = GetLocalGuildStreams(followedStream.CreateKey(), guild.Id); + streams.Remove(followedStream); + + PublishUnfollowStream(followedStream); + } + } + + return Task.CompletedTask; + } + + public async Task ClearAllStreams(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FollowedStreams)); + uow.RemoveRange(gc.FollowedStreams); + + foreach (var s in gc.FollowedStreams) + await PublishUnfollowStream(s); + + uow.SaveChanges(); + + return gc.FollowedStreams.Count; + } + + public async Task UnfollowStreamAsync(ulong guildId, int index) + { + FollowedStream fs; + await using (var uow = _db.GetDbContext()) + { + var fss = uow.Set() + .AsQueryable() + .Where(x => x.GuildId == guildId) + .OrderBy(x => x.Id) + .ToList(); + + // out of range + if (fss.Count <= index) + return null; + + fs = fss[index]; + uow.Remove(fs); + + await uow.SaveChangesAsync(); + + // remove from local cache + lock (_shardLock) + { + var key = fs.CreateKey(); + var streams = GetLocalGuildStreams(key, guildId); + streams.Remove(fs); + } + } + + await PublishUnfollowStream(fs); + + return fs; + } + + private void PublishFollowStream(FollowedStream fs) + => _pubSub.Pub(_streamFollowKey, + new() + { + Key = fs.CreateKey(), + GuildId = fs.GuildId + }); + + private Task PublishUnfollowStream(FollowedStream fs) + => _pubSub.Pub(_streamUnfollowKey, + new() + { + Key = fs.CreateKey(), + GuildId = fs.GuildId + }); + + public async Task FollowStream(ulong guildId, ulong channelId, string url) + { + // this will + var data = await _streamTracker.GetStreamDataByUrlAsync(url); + + if (data is null) + return null; + + FollowedStream fs; + await using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FollowedStreams)); + + // add it to the database + fs = new() + { + Type = data.StreamType, + Username = data.UniqueName, + ChannelId = channelId, + GuildId = guildId + }; + + var config = _config.Data; + if (config.FollowedStreams.MaxCount is not -1 + && gc.FollowedStreams.Count >= config.FollowedStreams.MaxCount) + return null; + + gc.FollowedStreams.Add(fs); + await uow.SaveChangesAsync(); + + // add it to the local cache of tracked streams + // this way this shard will know it needs to post a message to discord + // when shard 0 publishes stream status changes for this stream + lock (_shardLock) + { + var key = data.CreateKey(); + var streams = GetLocalGuildStreams(key, guildId); + streams.Add(fs); + } + } + + PublishFollowStream(fs); + + return data; + } + + public EmbedBuilder GetEmbed(ulong guildId, StreamData status, bool showViewers = true) + { + var embed = _sender.CreateEmbed() + .WithTitle(status.Name) + .WithUrl(status.StreamUrl) + .WithDescription(status.StreamUrl) + .AddField(GetText(guildId, strs.status), status.IsLive ? "🟢 Online" : "🔴 Offline", true); + + if (showViewers) + { + embed.AddField(GetText(guildId, strs.viewers), + status.Viewers == 0 && !status.IsLive + ? "-" + : status.Viewers, + true); + } + + if (status.IsLive) + embed = embed.WithOkColor(); + else + embed = embed.WithErrorColor(); + + if (!string.IsNullOrWhiteSpace(status.Title)) + embed.WithAuthor(status.Title); + + if (!string.IsNullOrWhiteSpace(status.Game)) + embed.AddField(GetText(guildId, strs.streaming), status.Game, true); + + if (!string.IsNullOrWhiteSpace(status.AvatarUrl)) + embed.WithThumbnailUrl(status.AvatarUrl); + + if (!string.IsNullOrWhiteSpace(status.Preview)) + embed.WithImageUrl(status.Preview + "?dv=" + _rng.Next()); + + return embed; + } + + private string GetText(ulong guildId, LocStr str) + => _strings.GetText(str, guildId); + + public bool ToggleStreamOffline(ulong guildId) + { + bool newValue; + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + newValue = gc.NotifyStreamOffline = !gc.NotifyStreamOffline; + uow.SaveChanges(); + + if (newValue) + _offlineNotificationServers.Add(guildId); + else + _offlineNotificationServers.TryRemove(guildId); + + return newValue; + } + + public bool ToggleStreamOnlineDelete(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var newValue = gc.DeleteStreamOnlineMessage = !gc.DeleteStreamOnlineMessage; + uow.SaveChanges(); + + if (newValue) + _deleteOnOfflineServers.Add(guildId); + else + _deleteOnOfflineServers.TryRemove(guildId); + + return newValue; + } + + public Task GetStreamDataAsync(string url) + => _streamTracker.GetStreamDataByUrlAsync(url); + + private HashSet GetLocalGuildStreams(in StreamDataKey key, ulong guildId) + { + if (_shardTrackedStreams.TryGetValue(key, out var map)) + { + if (map.TryGetValue(guildId, out var set)) + return set; + return map[guildId] = []; + } + + _shardTrackedStreams[key] = new() + { + { guildId, [] } + }; + return _shardTrackedStreams[key][guildId]; + } + + public bool SetStreamMessage( + ulong guildId, + int index, + string message, + out FollowedStream fs) + { + using var uow = _db.GetDbContext(); + var fss = uow.Set().AsQueryable().Where(x => x.GuildId == guildId).OrderBy(x => x.Id).ToList(); + + if (fss.Count <= index) + { + fs = null; + return false; + } + + fs = fss[index]; + fs.Message = message; + lock (_shardLock) + { + var streams = GetLocalGuildStreams(fs.CreateKey(), guildId); + + // message doesn't participate in equality checking + // removing and adding = update + streams.Remove(fs); + streams.Add(fs); + } + + uow.SaveChanges(); + + return true; + } + + public int SetStreamMessageForAll(ulong guildId, string message) + { + using var uow = _db.GetDbContext(); + + var all = uow.Set() + .Where(x => x.GuildId == guildId) + .ToList(); + + if (all.Count == 0) + return 0; + + all.ForEach(x => x.Message = message); + + uow.SaveChanges(); + + lock (_shardLock) + { + foreach (var fs in all) + { + var streams = GetLocalGuildStreams(fs.CreateKey(), guildId); + + // message doesn't participate in equality checking + // removing and adding = update + streams.Remove(fs); + streams.Add(fs); + } + } + + return all.Count; + } + + public sealed class FollowStreamPubData + { + public StreamDataKey Key { get; init; } + public ulong GuildId { get; init; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs new file mode 100644 index 0000000..ae51752 --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs @@ -0,0 +1,99 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Common; + +namespace EllieBot.Modules.Searches.Services; + +public sealed class StreamOnlineMessageDeleterService : IEService, IReadyExecutor +{ + private readonly StreamNotificationService _notifService; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IPubSub _pubSub; + + public StreamOnlineMessageDeleterService( + StreamNotificationService notifService, + DbService db, + IPubSub pubSub, + DiscordSocketClient client) + { + _notifService = notifService; + _db = db; + _client = client; + _pubSub = pubSub; + } + + public async Task OnReadyAsync() + { + _notifService.OnlineMessagesSent += OnOnlineMessagesSent; + + if (_client.ShardId == 0) + await _pubSub.Sub(_notifService.StreamsOfflineKey, OnStreamsOffline); + } + + private async Task OnOnlineMessagesSent( + FollowedStream.FType type, + string name, + IReadOnlyCollection<(ulong, ulong)> pairs) + { + await using var ctx = _db.GetDbContext(); + foreach (var (channelId, messageId) in pairs) + { + await ctx.GetTable() + .InsertAsync(() => new() + { + Name = name, + Type = type, + MessageId = messageId, + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }); + } + } + + private async ValueTask OnStreamsOffline(List streamDatas) + { + if (_client.ShardId != 0) + return; + + var pairs = await GetMessagesToDelete(streamDatas); + + foreach (var (channelId, messageId) in pairs) + { + try + { + var textChannel = await _client.GetChannelAsync(channelId) as ITextChannel; + if (textChannel is null) + continue; + + await textChannel.DeleteMessageAsync(messageId); + } + catch + { + continue; + } + } + } + + private async Task> GetMessagesToDelete(List streamDatas) + { + await using var ctx = _db.GetDbContext(); + + var toReturn = new List<(ulong, ulong)>(); + foreach (var sd in streamDatas) + { + var key = sd.CreateKey(); + var toDelete = await ctx.GetTable() + .Where(x => (x.Type == key.Type && x.Name == key.Name) + || Sql.DateDiff(Sql.DateParts.Day, x.DateAdded, DateTime.UtcNow) > 1) + .DeleteWithOutputAsync(); + + toReturn.AddRange(toDelete.Select(x => (x.ChannelId, x.MessageId))); + } + + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs b/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs new file mode 100644 index 0000000..6766b6f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public interface ITranslateService +{ + public Task Translate(string source, string target, string text = null); + Task ToggleAtl(ulong guildId, ulong channelId, bool autoDelete); + IEnumerable GetLanguages(); + + Task RegisterUserAsync( + ulong userId, + ulong channelId, + string from, + string to); + + Task UnregisterUser(ulong channelId, ulong userId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/TranslateService.cs b/src/EllieBot/Modules/Searches/Translate/TranslateService.cs new file mode 100644 index 0000000..9f50615 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/TranslateService.cs @@ -0,0 +1,224 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Net; + +namespace EllieBot.Modules.Searches; + +public sealed class TranslateService : ITranslateService, IExecNoCommand, IReadyExecutor, IEService +{ + private readonly IGoogleApiService _google; + private readonly DbService _db; + private readonly IMessageSenderService _sender; + private readonly IBot _bot; + + private readonly ConcurrentDictionary _atcs = new(); + private readonly ConcurrentDictionary> _users = new(); + + public TranslateService( + IGoogleApiService google, + DbService db, + IMessageSenderService sender, + IBot bot) + { + _google = google; + _db = db; + _sender = sender; + _bot = bot; + } + + public async Task OnReadyAsync() + { + List cs; + await using (var ctx = _db.GetDbContext()) + { + var guilds = _bot.AllGuildConfigs.Select(x => x.GuildId).ToList(); + cs = await ctx.Set().Include(x => x.Users) + .Where(x => guilds.Contains(x.GuildId)) + .ToListAsyncEF(); + } + + foreach (var c in cs) + { + _atcs[c.ChannelId] = c.AutoDelete; + _users[c.ChannelId] = + new(c.Users.ToDictionary(x => x.UserId, x => (x.Source.ToLower(), x.Target.ToLower()))); + } + } + + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (string.IsNullOrWhiteSpace(msg.Content)) + return; + + if (msg is { Channel: ITextChannel tch } um) + { + if (!_atcs.TryGetValue(tch.Id, out var autoDelete)) + return; + + if (!_users.TryGetValue(tch.Id, out var users) || !users.TryGetValue(um.Author.Id, out var langs)) + return; + + var output = await _google.Translate(msg.Content, langs.From, langs.To); + + if (string.IsNullOrWhiteSpace(output) + || msg.Content.Equals(output, StringComparison.InvariantCultureIgnoreCase)) + return; + + var embed = _sender.CreateEmbed().WithOkColor(); + + if (autoDelete) + { + embed.WithAuthor(um.Author.ToString(), um.Author.GetAvatarUrl()) + .AddField(langs.From, um.Content) + .AddField(langs.To, output); + + await _sender.Response(tch).Embed(embed).SendAsync(); + + try + { + await um.DeleteAsync(); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + _atcs.TryUpdate(tch.Id, false, true); + } + + return; + } + + await um.ReplyAsync(embed: embed.AddField(langs.To, output).Build(), allowedMentions: AllowedMentions.None); + } + } + + public async Task Translate(string source, string target, string text = null) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text is empty or null", nameof(text)); + + var res = await _google.Translate(text, source.ToLowerInvariant(), target.ToLowerInvariant()); + return res.SanitizeMentions(true); + } + + public async Task ToggleAtl(ulong guildId, ulong channelId, bool autoDelete) + { + await using var ctx = _db.GetDbContext(); + + var old = await ctx.Set().ToLinqToDBTable() + .FirstOrDefaultAsyncLinqToDB(x => x.ChannelId == channelId); + + if (old is null) + { + ctx.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + AutoDelete = autoDelete + }); + + await ctx.SaveChangesAsync(); + + _atcs[channelId] = autoDelete; + _users[channelId] = new(); + + return true; + } + + // if autodelete value is different, update the autodelete value + // instead of disabling + if (old.AutoDelete != autoDelete) + { + old.AutoDelete = autoDelete; + await ctx.SaveChangesAsync(); + _atcs[channelId] = autoDelete; + return true; + } + + await ctx.Set().ToLinqToDBTable().DeleteAsync(x => x.ChannelId == channelId); + + await ctx.SaveChangesAsync(); + _atcs.TryRemove(channelId, out _); + _users.TryRemove(channelId, out _); + + return false; + } + + + private void UpdateUser( + ulong channelId, + ulong userId, + string from, + string to) + { + var dict = _users.GetOrAdd(channelId, new ConcurrentDictionary()); + dict[userId] = (from, to); + } + + public async Task RegisterUserAsync( + ulong userId, + ulong channelId, + string from, + string to) + { + if (!_google.Languages.ContainsKey(from) || !_google.Languages.ContainsKey(to)) + return null; + + await using var ctx = _db.GetDbContext(); + var ch = await ctx.Set().GetByChannelId(channelId); + + if (ch is null) + return null; + + var user = ch.Users.FirstOrDefault(x => x.UserId == userId); + + if (user is null) + { + ch.Users.Add(user = new() + { + Source = from, + Target = to, + UserId = userId + }); + + await ctx.SaveChangesAsync(); + + UpdateUser(channelId, userId, from, to); + + return true; + } + + // if it's different from old settings, update + if (user.Source != from || user.Target != to) + { + user.Source = from; + user.Target = to; + + await ctx.SaveChangesAsync(); + + UpdateUser(channelId, userId, from, to); + + return true; + } + + return await UnregisterUser(channelId, userId); + } + + public async Task UnregisterUser(ulong channelId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set().ToLinqToDBTable() + .DeleteAsync(x => x.UserId == userId && x.Channel.ChannelId == channelId); + + if (_users.TryGetValue(channelId, out var inner)) + inner.TryRemove(userId, out _); + + return rows > 0; + } + + public IEnumerable GetLanguages() + => _google.Languages.GroupBy(x => x.Value).Select(x => $"{x.AsEnumerable().Select(y => y.Key).Join(", ")}"); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs b/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs new file mode 100644 index 0000000..348ca61 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs @@ -0,0 +1,95 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class TranslateCommands : EllieModule + { + public enum AutoDeleteAutoTranslate + { + Del, + Nodel + } + + [Cmd] + public async Task Translate(string fromLang, string toLang, [Leftover] string text = null) + { + try + { + await ctx.Channel.TriggerTypingAsync(); + var translation = await _service.Translate(fromLang, toLang, text); + + var embed = _sender.CreateEmbed().WithOkColor().AddField(fromLang, text).AddField(toLang, translation); + + await Response().Embed(embed).SendAsync(); + } + catch + { + await Response().Error(strs.bad_input_format).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(ChannelPerm.ManageMessages)] + [OwnerOnly] + public async Task AutoTranslate(AutoDeleteAutoTranslate autoDelete = AutoDeleteAutoTranslate.Nodel) + { + var toggle = + await _service.ToggleAtl(ctx.Guild.Id, ctx.Channel.Id, autoDelete == AutoDeleteAutoTranslate.Del); + if (toggle) + await Response().Confirm(strs.atl_started).SendAsync(); + else + await Response().Confirm(strs.atl_stopped).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AutoTransLang() + { + if (await _service.UnregisterUser(ctx.Channel.Id, ctx.User.Id)) + await Response().Confirm(strs.atl_removed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AutoTransLang(string fromLang, string toLang) + { + var succ = await _service.RegisterUserAsync(ctx.User.Id, ctx.Channel.Id, fromLang.ToLower(), toLang.ToLower()); + + if (succ is null) + { + await Response().Error(strs.atl_not_enabled).SendAsync(); + return; + } + + if (succ is false) + { + await Response().Error(strs.invalid_lang).SendAsync(); + return; + } + + await Response().Confirm(strs.atl_set(fromLang, toLang)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Translangs() + { + var langs = _service.GetLanguages().ToList(); + + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.supported_languages)) + .WithOkColor(); + + foreach (var chunk in langs.Chunk(15)) + { + eb.AddField("󠀁", chunk.Join("\n"), inline: true); + } + + await Response().Embed(eb).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/XkcdCommands.cs b/src/EllieBot/Modules/Searches/XkcdCommands.cs new file mode 100644 index 0000000..d913a87 --- /dev/null +++ b/src/EllieBot/Modules/Searches/XkcdCommands.cs @@ -0,0 +1,97 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class XkcdCommands : EllieModule + { + private const string XKCD_URL = "https://xkcd.com"; + private readonly IHttpClientFactory _httpFactory; + + public XkcdCommands(IHttpClientFactory factory) + => _httpFactory = factory; + + [Cmd] + [Priority(0)] + public async Task Xkcd(string arg = null) + { + if (arg?.ToLowerInvariant().Trim() == "latest") + { + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"{XKCD_URL}/info.0.json"); + var comic = JsonConvert.DeserializeObject(res); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithImageUrl(comic.ImageLink) + .WithAuthor(comic.Title, "https://xkcd.com/s/919f27.ico", $"{XKCD_URL}/{comic.Num}") + .AddField(GetText(strs.comic_number), comic.Num.ToString(), true) + .AddField(GetText(strs.date), $"{comic.Month}/{comic.Year}", true); + var sent = await Response().Embed(embed).SendAsync(); + + await Task.Delay(10000); + + await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build()); + } + catch (HttpRequestException) + { + await Response().Error(strs.comic_not_found).SendAsync(); + } + + return; + } + + await Xkcd(new EllieRandom().Next(1, 1750)); + } + + [Cmd] + [Priority(1)] + public async Task Xkcd(int num) + { + if (num < 1) + return; + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"{XKCD_URL}/{num}/info.0.json"); + + var comic = JsonConvert.DeserializeObject(res); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithImageUrl(comic.ImageLink) + .WithAuthor(comic.Title, "https://xkcd.com/s/919f27.ico", $"{XKCD_URL}/{num}") + .AddField(GetText(strs.comic_number), comic.Num.ToString(), true) + .AddField(GetText(strs.date), $"{comic.Month}/{comic.Year}", true); + + var sent = await Response().Embed(embed).SendAsync(); + + await Task.Delay(10000); + + await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build()); + } + catch (HttpRequestException) + { + await Response().Error(strs.comic_not_found).SendAsync(); + } + } + } + + public class XkcdComic + { + public int Num { get; set; } + public string Month { get; set; } + public string Year { get; set; } + + [JsonProperty("safe_title")] + public string Title { get; set; } + + [JsonProperty("img")] + public string ImageLink { get; set; } + + public string Alt { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs b/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs new file mode 100644 index 0000000..6cbe065 --- /dev/null +++ b/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs @@ -0,0 +1,134 @@ +#nullable disable + +// public class YtTrackService : IEService +// { +// private readonly IGoogleApiService _google; +// private readonly IHttpClientFactory httpClientFactory; +// private readonly DiscordSocketClient _client; +// private readonly DbService _db; +// private readonly ConcurrentDictionary>> followedChannels; +// private readonly ConcurrentDictionary _latestPublishes = new ConcurrentDictionary(); +// +// public YtTrackService(IGoogleApiService google, IHttpClientFactory httpClientFactory, DiscordSocketClient client, +// DbService db) +// { +// this._google = google; +// this.httpClientFactory = httpClientFactory; +// this._client = client; +// this._db = db; +// +// if (_client.ShardId == 0) +// { +// _ = CheckLoop(); +// } +// } +// +// public async Task CheckLoop() +// { +// while (true) +// { +// await Task.Delay(10000); +// using (var http = httpClientFactory.CreateClient()) +// { +// await followedChannels.Select(kvp => CheckChannel(kvp.Key, kvp.Value.SelectMany(x => x.Value).ToList())).WhenAll(); +// } +// } +// } +// +// /// +// /// Checks the specified youtube channel, and sends a message to all provided +// /// +// /// Id of the youtube channel +// /// Where to post updates if there is a new update +// private async Task CheckChannel(string youtubeChannelId, List followedChannels) +// { +// var latestVid = (await _google.GetLatestChannelVideosAsync(youtubeChannelId, 1)) +// .FirstOrDefault(); +// if (latestVid is null) +// { +// return; +// } +// +// if (_latestPublishes.TryGetValue(youtubeChannelId, out var latestPub) && latestPub >= latestVid.PublishedAt) +// { +// return; +// } +// _latestPublishes[youtubeChannelId] = latestVid.PublishedAt; +// +// foreach (var chObj in followedChannels) +// { +// var gCh = _client.GetChannel(chObj.ChannelId); +// if (gCh is ITextChannel ch) +// { +// var msg = latestVid.GetVideoUrl(); +// if (!string.IsNullOrWhiteSpace(chObj.UploadMessage)) +// msg = chObj.UploadMessage + Environment.NewLine + msg; +// +// await ch.SendMessageAsync(msg); +// } +// } +// } +// +// /// +// /// Starts posting updates on the specified discord channel when a new video is posted on the specified YouTube channel. +// /// +// /// Id of the discord guild +// /// Id of the discord channel +// /// Id of the youtube channel +// /// Message to post when a new video is uploaded, along with video URL +// /// Whether adding was successful +// public async Task ToggleChannelFollowAsync(ulong guildId, ulong channelId, string ytChannelId, string uploadMessage) +// { +// // to to see if we can get a video from that channel +// var vids = await _google.GetLatestChannelVideosAsync(ytChannelId, 1); +// if (vids.Count == 0) +// return false; +// +// using(var uow = _db.GetDbContext()) +// { +// var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.YtFollowedChannels)); +// +// // see if this yt channel was already followed on this discord channel +// var oldObj = gc.YtFollowedChannels +// .FirstOrDefault(x => x.ChannelId == channelId && x.YtChannelId == ytChannelId); +// +// if(oldObj is not null) +// { +// return false; +// } +// +// // can only add up to 10 tracked channels per server +// if (gc.YtFollowedChannels.Count >= 10) +// { +// return false; +// } +// +// var obj = new YtFollowedChannel +// { +// ChannelId = channelId, +// YtChannelId = ytChannelId, +// UploadMessage = uploadMessage +// }; +// +// // add to database +// gc.YtFollowedChannels.Add(obj); +// +// // add to the local cache: +// +// // get follows on all guilds +// var allGuildFollows = followedChannels.GetOrAdd(ytChannelId, new ConcurrentDictionary>()); +// // add to this guild's follows +// allGuildFollows.AddOrUpdate(guildId, +// new List(), +// (key, old) => +// { +// old.Add(obj); +// return old; +// }); +// +// await uow.SaveChangesAsync(); +// } +// +// return true; +// } +// } \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs b/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs new file mode 100644 index 0000000..2439ad4 --- /dev/null +++ b/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs @@ -0,0 +1,54 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + // [Group] + // public partial class YtTrackCommands : EllieModule + // { + // ; + // [RequireContext(ContextType.Guild)] + // public async Task YtFollow(string ytChannelId, [Leftover] string uploadMessage = null) + // { + // var succ = await _service.ToggleChannelFollowAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // if(succ) + // { + // await Response().Confirm(strs.yt_follow_added).SendAsync(); + // } + // else + // { + // await Response().Confirm(strs.yt_follow_fail).SendAsync(); + // } + // } + // + // [EllieCommand, Usage, Description, Aliases] + // [RequireContext(ContextType.Guild)] + // public async Task YtTrackRm(int index) + // { + // //var succ = await _service.ToggleChannelTrackingAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // //if (succ) + // //{ + // // await Response().Confirm(strs.yt_track_added).SendAsync(); + // //} + // //else + // //{ + // // await Response().Confirm(strs.yt_track_fail).SendAsync(); + // //} + // } + // + // [EllieCommand, Usage, Description, Aliases] + // [RequireContext(ContextType.Guild)] + // public async Task YtTrackList() + // { + // //var succ = await _service.ToggleChannelTrackingAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // //if (succ) + // //{ + // // await Response().Confirm(strs.yt_track_added).SendAsync(); + // //} + // //else + // //{ + // // await Response().Confirm(strs.yt_track_fail).SendAsync(); + // //} + // } + // } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs b/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs new file mode 100644 index 0000000..e8ab960 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs @@ -0,0 +1,12 @@ +#nullable disable +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Searches; + +public static class AtlExtensions +{ + public static Task GetByChannelId(this IQueryable set, ulong channelId) + => set.Include(x => x.Users).FirstOrDefaultAsyncEF(x => x.ChannelId == channelId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/BibleVerses.cs b/src/EllieBot/Modules/Searches/_common/BibleVerses.cs new file mode 100644 index 0000000..30dd045 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/BibleVerses.cs @@ -0,0 +1,20 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class BibleVerses +{ + public string Error { get; set; } + public BibleVerse[] Verses { get; set; } +} + +public class BibleVerse +{ + [JsonPropertyName("book_name")] + public string BookName { get; set; } + + public int Chapter { get; set; } + public int Verse { get; set; } + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs b/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs new file mode 100644 index 0000000..b34fb36 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public enum ImgSearchEngine +{ + Google, + Searx, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs new file mode 100644 index 0000000..fb64849 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs @@ -0,0 +1,86 @@ +using Cloneable; +using EllieBot.Common.Yml; + +namespace EllieBot.Modules.Searches; + +[Cloneable] +public partial class SearchesConfig : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 0; + + [Comment(""" + Which engine should .search command + 'google_scrape' - default. Scrapes the webpage for results. May break. Requires no api keys. + 'google' - official google api. Requires googleApiKey and google.searchId set in creds.yml + 'searx' - requires at least one searx instance specified in the 'searxInstances' property below + """)] + public WebSearchEngine WebSearchEngine { get; set; } = WebSearchEngine.Google_Scrape; + + [Comment(""" + Which engine should .image command use + 'google'- official google api. googleApiKey and google.imageSearchId set in creds.yml + 'searx' requires at least one searx instance specified in the 'searxInstances' property below + """)] + public ImgSearchEngine ImgSearchEngine { get; set; } = ImgSearchEngine.Google; + + + [Comment(""" + Which search provider will be used for the `.youtube` command. + + - `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console + + - `ytdl` - default, uses youtube-dl. Requires `youtube-dl` to be installed and it's path added to env variables. Slow. + + - `ytdlp` - recommended easy, uses `yt-dlp`. Requires `yt-dlp` to be installed and it's path added to env variables + + - `invidious` - recommended advanced, uses invidious api. Requires at least one invidious instance specified in the `invidiousInstances` property + """)] + public YoutubeSearcher YtProvider { get; set; } = YoutubeSearcher.Ytdlp; + + [Comment(""" + Set the searx instance urls in case you want to use 'searx' for either img or web search. + Ellie will use a random one for each request. + Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com` + Instances specified must support 'format=json' query parameter. + - In case you're running your own searx instance, set + + search: + formats: + - json + + in 'searxng/settings.yml' on your server + + - If you're using a public instance, make sure that the instance you're using supports it (they usually don't) + """)] + public List SearxInstances { get; set; } = new List(); + + [Comment(""" + Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search + Ellie will use a random one for each request. + These instances may be used for music queue functionality in the future. + Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com + + Instances specified must have api available. + You check that by opening an api endpoint in your browser. For example: https://my-invidious-instance.mydomain.com/api/v1/trending + """)] + public List InvidiousInstances { get; set; } = new List(); + + [Comment("Maximum number of followed streams per server")] + public FollowedStreamConfig FollowedStreams { get; set; } = new FollowedStreamConfig(); +} + +public sealed class FollowedStreamConfig +{ + [Comment("Maximum number of streams that each server can follow. -1 for infinite")] + public int MaxCount { get; set; } = 10; +} + +public enum YoutubeSearcher +{ + YtDataApiv3, + Ytdl, + Ytdlp, + Invid, + Invidious = 3 +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs new file mode 100644 index 0000000..f656a5b --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs @@ -0,0 +1,58 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Modules.Searches; + +public class SearchesConfigService : ConfigServiceBase +{ + private static string FILE_PATH = "data/searches.yml"; + private static readonly TypedKey _changeKey = new("config.searches.updated"); + + public override string Name + => "searches"; + + public SearchesConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("webEngine", + sc => sc.WebSearchEngine, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("imgEngine", + sc => sc.ImgSearchEngine, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("ytProvider", + sc => sc.YtProvider, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("followedStreams.maxCount", + sc => sc.FollowedStreams.MaxCount, + int.TryParse, + ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 1) + { + ModifyConfig(c => + { + c.Version = 1; + c.WebSearchEngine = WebSearchEngine.Google_Scrape; + }); + } + + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Version = 2; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs b/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs new file mode 100644 index 0000000..e924f03 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs @@ -0,0 +1,9 @@ +// ReSharper disable InconsistentNaming +namespace EllieBot.Modules.Searches; + +public enum WebSearchEngine +{ + Google, + Google_Scrape, + Searx, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/CryptoData.cs b/src/EllieBot/Modules/Searches/_common/CryptoData.cs new file mode 100644 index 0000000..6600b59 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/CryptoData.cs @@ -0,0 +1,66 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class CryptoResponse +{ + public List Data { get; set; } +} + +public class CmcQuote +{ + [JsonPropertyName("price")] + public double Price { get; set; } + + [JsonPropertyName("volume_24h")] + public double Volume24h { get; set; } + + // [JsonPropertyName("volume_change_24h")] + // public double VolumeChange24h { get; set; } + // + // [JsonPropertyName("percent_change_1h")] + // public double PercentChange1h { get; set; } + + [JsonPropertyName("percent_change_24h")] + public double PercentChange24h { get; set; } + + [JsonPropertyName("percent_change_7d")] + public double PercentChange7d { get; set; } + + [JsonPropertyName("market_cap")] + public double MarketCap { get; set; } + + [JsonPropertyName("market_cap_dominance")] + public double MarketCapDominance { get; set; } +} + +public class CmcResponseData +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("cmc_rank")] + public int CmcRank { get; set; } + + [JsonPropertyName("circulating_supply")] + public double? CirculatingSupply { get; set; } + + [JsonPropertyName("total_supply")] + public double? TotalSupply { get; set; } + + [JsonPropertyName("max_supply")] + public double? MaxSupply { get; set; } + + [JsonPropertyName("quote")] + public Dictionary Quote { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/DefineModel.cs b/src/EllieBot/Modules/Searches/_common/DefineModel.cs new file mode 100644 index 0000000..a0e2018 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/DefineModel.cs @@ -0,0 +1,43 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches.Common; + +public class Audio +{ + public string Url { get; set; } +} + +public class Example +{ + public List