diff --git a/.dockerignore b/.dockerignore index ea37ec39..ac2851ee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,9 +3,16 @@ # Include project code and build files !PluralKit.*/ +!gateway/ +!myriad_rs/ +!Myriad/ !PluralKit.sln !nuget.config +!.git +!proto +!scripts/run-clustered.sh # Re-exclude host build artifact directories **/bin -**/obj \ No newline at end of file +**/obj +**/target \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 7f652d9f..73cf0050 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,53 +1,52 @@ - [*] -charset=utf-8 -end_of_line=lf -trim_trailing_whitespace=false -insert_final_newline=false -indent_style=space -indent_size=4 +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = false +insert_final_newline = false +indent_style = space +indent_size = 4 # Microsoft .NET properties -csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion -csharp_space_before_colon_in_inheritance_clause=false -csharp_style_var_elsewhere=true:hint -csharp_style_var_for_built_in_types=true:hint -csharp_style_var_when_type_is_apparent=true:hint -dotnet_style_predefined_type_for_locals_parameters_members=true:hint -dotnet_style_predefined_type_for_member_access=true:hint -dotnet_style_qualification_for_event=false:warning -dotnet_style_qualification_for_field=false:warning -dotnet_style_qualification_for_method=false:warning -dotnet_style_qualification_for_property=false:warning -dotnet_style_require_accessibility_modifiers=for_non_interface_members:hint +csharp_preferred_modifier_order = public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion +csharp_space_before_colon_in_inheritance_clause = false +csharp_style_var_elsewhere = true:hint +csharp_style_var_for_built_in_types = true:hint +csharp_style_var_when_type_is_apparent = true:hint +dotnet_style_predefined_type_for_locals_parameters_members = true:hint +dotnet_style_predefined_type_for_member_access = true:hint +dotnet_style_qualification_for_event = false:warning +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:hint # ReSharper properties -resharper_align_multiline_parameter=true -resharper_autodetect_indent_settings=true -resharper_blank_lines_between_using_groups=1 -resharper_braces_for_using=required_for_multiline -resharper_csharp_stick_comment=false -resharper_empty_block_style=together_same_line -resharper_keep_existing_attribute_arrangement=true -resharper_keep_existing_initializer_arrangement=false -resharper_local_function_body=expression_body -resharper_method_or_operator_body=expression_body -resharper_place_accessor_with_attrs_holder_on_single_line=true -resharper_place_simple_case_statement_on_same_line=if_owner_is_single_line -resharper_space_before_type_parameter_constraint_colon=false -resharper_use_indent_from_vs=false -resharper_wrap_before_first_type_parameter_constraint=true +resharper_align_multiline_parameter = true +resharper_autodetect_indent_settings = true +resharper_blank_lines_between_using_groups = 1 +resharper_braces_for_using = required_for_multiline +resharper_csharp_stick_comment = false +resharper_empty_block_style = together_same_line +resharper_keep_existing_attribute_arrangement = true +resharper_keep_existing_initializer_arrangement = false +resharper_local_function_body = expression_body +resharper_method_or_operator_body = expression_body +resharper_place_accessor_with_attrs_holder_on_single_line = true +resharper_place_simple_case_statement_on_same_line = if_owner_is_single_line +resharper_space_before_type_parameter_constraint_colon = false +resharper_use_indent_from_vs = false +resharper_wrap_before_first_type_parameter_constraint = true # ReSharper inspection severities: -resharper_web_config_module_not_resolved_highlighting=warning -resharper_web_config_type_not_resolved_highlighting=warning -resharper_web_config_wrong_module_highlighting=warning +resharper_web_config_module_not_resolved_highlighting = warning +resharper_web_config_type_not_resolved_highlighting = warning +resharper_web_config_wrong_module_highlighting = warning [{*.yml,*.yaml}] -indent_style=space -indent_size=2 +indent_style = space +indent_size = 2 [*.{appxmanifest,asax,ascx,aspx,build,config,cs,cshtml,csproj,dbml,discomap,dtd,fs,fsi,fsscript,fsx,htm,html,jsproj,lsproj,master,ml,mli,njsproj,nuspec,proj,props,razor,resw,resx,skin,StyleCop,targets,tasks,vb,vbproj,xaml,xamlx,xml,xoml,xsd}] -indent_style=space -indent_size=4 -tab_width=4 +indent_style = space +indent_size = 4 +tab_width = 4 diff --git a/.github/workflows/beta-bot.yml b/.github/workflows/beta-bot.yml new file mode 100644 index 00000000..e9f34a8b --- /dev/null +++ b/.github/workflows/beta-bot.yml @@ -0,0 +1,14 @@ +name: "Update Beta Bot" +on: + push: + branches: [dev] + +jobs: + update-bot: + runs-on: ubuntu-latest + steps: + - name: "Update Beta Bot" + uses: fjogeleit/http-request-action@master + with: + url: https://api-beta.pluralkit.me/v1/update + bearerToken: ${{ secrets.WATCHTOWER_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..8dbcdc7c --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,29 @@ +name: Build and push Docker image +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + packages: write + if: github.repository == 'xSke/PluralKit' + steps: + - uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.CR_PAT }} + - uses: actions/checkout@v2 + - run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - uses: docker/build-push-action@v2 + with: + # https://github.com/docker/build-push-action/issues/378 + context: . + push: true + tags: | + ghcr.io/xske/pluralkit:${{ env.BRANCH_NAME }} + ghcr.io/xske/pluralkit:${{ github.sha }} + ghcr.io/xske/pluralkit:latest + cache-from: type=registry,ref=ghcr.io/xske/pluralkit:${{ env.BRANCH_NAME }} + cache-to: type=inline diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index c81953cd..89f4775c 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -12,6 +12,18 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 5.0.x - - name: Build and test with dotnet + dotnet-version: 6.0.x + - name: Build with dotnet + run: dotnet build --configuration Release + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + with: + submodules: recursive + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 6.0.x + - name: Run automated tests run: dotnet test --configuration Release diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml new file mode 100644 index 00000000..9bd64c82 --- /dev/null +++ b/.github/workflows/gateway.yml @@ -0,0 +1,35 @@ +name: Build gateway Docker image + +on: + push: + branches: [main] + paths: + - 'gateway/' + - 'proto/' + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + packages: write + if: github.repository == 'xSke/PluralKit' + steps: + - uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.CR_PAT }} + - uses: actions/checkout@v2 + - run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - uses: docker/build-push-action@v2 + with: + # https://github.com/docker/build-push-action/issues/378 + context: . + file: Dockerfile.gateway + push: true + tags: | + ghcr.io/pluralkit/gateway:${{ env.BRANCH_NAME }} + ghcr.io/pluralkit/gateway:${{ github.sha }} + ghcr.io/pluralkit/gateway:latest + cache-from: type=registry,ref=ghcr.io/pluralkit/gateway:${{ env.BRANCH_NAME }} + cache-to: type=inline diff --git a/.gitignore b/.gitignore index 35c2cd6f..8edae534 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,16 @@ bin/ obj/ +# rust build files +target/ + # IDE files .idea/ +.run/ .vscode/ tags/ +.DS_Store +mono_crash* # Dependencies node_modules/ @@ -17,4 +23,5 @@ pluralkit.*.conf *.DotSettings.user # Generated -logs/ \ No newline at end of file +logs/ +.version diff --git a/Dockerfile b/Dockerfile index 82090e41..3930b9e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,34 @@ -FROM mcr.microsoft.com/dotnet/core/sdk:3.1.401 +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build + WORKDIR /app # Restore/fetch dependencies excluding app code to make use of caching -COPY PluralKit.sln nuget.config /app/ +COPY PluralKit.sln /app/ +COPY Myriad/Myriad.csproj /app/Myriad/ COPY PluralKit.API/PluralKit.API.csproj /app/PluralKit.API/ COPY PluralKit.Bot/PluralKit.Bot.csproj /app/PluralKit.Bot/ COPY PluralKit.Core/PluralKit.Core.csproj /app/PluralKit.Core/ +COPY PluralKit.ScheduledTasks/PluralKit.ScheduledTasks.csproj /app/PluralKit.ScheduledTasks/ COPY PluralKit.Tests/PluralKit.Tests.csproj /app/PluralKit.Tests/ +COPY .git/ /app/.git +COPY proto/ /app/proto RUN dotnet restore PluralKit.sln # Copy the rest of the code and build COPY . /app RUN dotnet build -c Release -o bin -# Run :) +# Build runtime stage (doesn't include SDK) +FROM mcr.microsoft.com/dotnet/aspnet:6.0 +LABEL org.opencontainers.image.source = "https://github.com/xSke/PluralKit" + +WORKDIR /app +COPY --from=build /app ./ + +# Runtime dependency in prod +RUN apt update && apt install -y curl +ADD scripts/run-clustered.sh / + # Allow overriding CMD from eg. docker-compose to run API layer too ENTRYPOINT ["dotnet"] -CMD ["bin/PluralKit.Bot.dll"] \ No newline at end of file +CMD ["bin/PluralKit.Bot.dll"] diff --git a/Dockerfile.gateway b/Dockerfile.gateway new file mode 100644 index 00000000..73132c89 --- /dev/null +++ b/Dockerfile.gateway @@ -0,0 +1,22 @@ +# twilight requires newer rustc than what is in alpine:latest +FROM alpine:edge AS builder + +RUN apk add cargo protobuf + +# Precache crates.io index +RUN cargo search >/dev/null + +WORKDIR /build +COPY proto/ /build/proto +COPY gateway/ /build/gateway +COPY myriad_rs/ /build/myriad_rs + +# todo: cache build of myriad_rs elsewhere + +RUN (cd gateway && cargo build --release) + +FROM alpine:latest + +COPY --from=builder /build/gateway/target/release/pluralkit /opt/gateway + +ENTRYPOINT ["/opt/gateway"] diff --git a/Myriad/Builders/EmbedBuilder.cs b/Myriad/Builders/EmbedBuilder.cs new file mode 100644 index 00000000..3f3e65f2 --- /dev/null +++ b/Myriad/Builders/EmbedBuilder.cs @@ -0,0 +1,73 @@ +using Myriad.Types; + +namespace Myriad.Builders; + +public class EmbedBuilder +{ + private readonly List _fields = new(); + private Embed _embed = new(); + + public EmbedBuilder Title(string? title) + { + _embed = _embed with { Title = title }; + return this; + } + + public EmbedBuilder Description(string? description) + { + _embed = _embed with { Description = description }; + return this; + } + + public EmbedBuilder Url(string? url) + { + _embed = _embed with { Url = url }; + return this; + } + + public EmbedBuilder Color(uint? color) + { + _embed = _embed with { Color = color }; + return this; + } + + public EmbedBuilder Footer(Embed.EmbedFooter? footer) + { + _embed = _embed with { Footer = footer }; + return this; + } + + public EmbedBuilder Image(Embed.EmbedImage? image) + { + _embed = _embed with { Image = image }; + return this; + } + + + public EmbedBuilder Thumbnail(Embed.EmbedThumbnail? thumbnail) + { + _embed = _embed with { Thumbnail = thumbnail }; + return this; + } + + public EmbedBuilder Author(Embed.EmbedAuthor? author) + { + _embed = _embed with { Author = author }; + return this; + } + + public EmbedBuilder Timestamp(string? timestamp) + { + _embed = _embed with { Timestamp = timestamp }; + return this; + } + + public EmbedBuilder Field(Embed.Field field) + { + _fields.Add(field); + return this; + } + + public Embed Build() => + _embed with { Fields = _fields.ToArray() }; +} \ No newline at end of file diff --git a/Myriad/Cache/DiscordCacheExtensions.cs b/Myriad/Cache/DiscordCacheExtensions.cs new file mode 100644 index 00000000..7a47f9f0 --- /dev/null +++ b/Myriad/Cache/DiscordCacheExtensions.cs @@ -0,0 +1,118 @@ +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Types; + +namespace Myriad.Cache; + +public static class DiscordCacheExtensions +{ + public static ValueTask HandleGatewayEvent(this IDiscordCache cache, IGatewayEvent evt) + { + switch (evt) + { + case ReadyEvent ready: + return cache.SaveOwnUser(ready.User.Id); + case GuildCreateEvent gc: + return cache.SaveGuildCreate(gc); + case GuildUpdateEvent gu: + return cache.SaveGuild(gu); + case GuildDeleteEvent gd: + return cache.RemoveGuild(gd.Id); + case ChannelCreateEvent cc: + return cache.SaveChannel(cc); + case ChannelUpdateEvent cu: + return cache.SaveChannel(cu); + case ChannelDeleteEvent cd: + return cache.RemoveChannel(cd.Id); + case GuildRoleCreateEvent grc: + return cache.SaveRole(grc.GuildId, grc.Role); + case GuildRoleUpdateEvent gru: + return cache.SaveRole(gru.GuildId, gru.Role); + case GuildRoleDeleteEvent grd: + return cache.RemoveRole(grd.GuildId, grd.RoleId); + case MessageReactionAddEvent mra: + return cache.TrySaveDmChannelStub(mra.GuildId, mra.ChannelId); + case MessageCreateEvent mc: + return cache.SaveMessageCreate(mc); + case MessageUpdateEvent mu: + return cache.TrySaveDmChannelStub(mu.GuildId.Value, mu.ChannelId); + case MessageDeleteEvent md: + return cache.TrySaveDmChannelStub(md.GuildId, md.ChannelId); + case MessageDeleteBulkEvent md: + return cache.TrySaveDmChannelStub(md.GuildId, md.ChannelId); + case ThreadCreateEvent tc: + return cache.SaveChannel(tc); + case ThreadUpdateEvent tu: + return cache.SaveChannel(tu); + case ThreadDeleteEvent td: + return cache.RemoveChannel(td.Id); + case ThreadListSyncEvent tls: + return cache.SaveThreadListSync(tls); + } + + return default; + } + + public static ValueTask TryUpdateSelfMember(this IDiscordCache cache, ulong userId, IGatewayEvent evt) + { + if (evt is GuildCreateEvent gc) + return cache.SaveSelfMember(gc.Id, gc.Members.FirstOrDefault(m => m.User.Id == userId)!); + if (evt is MessageCreateEvent mc && mc.Member != null && mc.Author.Id == userId) + return cache.SaveSelfMember(mc.GuildId!.Value, mc.Member); + if (evt is GuildMemberAddEvent gma && gma.User.Id == userId) + return cache.SaveSelfMember(gma.GuildId, gma); + if (evt is GuildMemberUpdateEvent gmu && gmu.User.Id == userId) + return cache.SaveSelfMember(gmu.GuildId, gmu); + + return default; + } + + private static async ValueTask SaveGuildCreate(this IDiscordCache cache, GuildCreateEvent guildCreate) + { + await cache.SaveGuild(guildCreate); + + foreach (var channel in guildCreate.Channels) + // The channel object does not include GuildId for some reason... + await cache.SaveChannel(channel with { GuildId = guildCreate.Id }); + + foreach (var member in guildCreate.Members) + await cache.SaveUser(member.User); + + foreach (var thread in guildCreate.Threads) + await cache.SaveChannel(thread); + } + + private static async ValueTask SaveMessageCreate(this IDiscordCache cache, MessageCreateEvent evt) + { + await cache.TrySaveDmChannelStub(evt.GuildId, evt.ChannelId); + + await cache.SaveUser(evt.Author); + foreach (var mention in evt.Mentions) + await cache.SaveUser(mention); + } + + private static ValueTask TrySaveDmChannelStub(this IDiscordCache cache, ulong? guildId, ulong channelId) => + // DM messages don't get Channel Create events first, so we need to save + // some kind of stub channel object until we get the real one + guildId != null ? default : cache.SaveDmChannelStub(channelId); + + private static async ValueTask SaveThreadListSync(this IDiscordCache cache, ThreadListSyncEvent evt) + { + foreach (var thread in evt.Threads) + await cache.SaveChannel(thread); + } + + public static async Task PermissionsIn(this IDiscordCache cache, ulong channelId) + { + var channel = await cache.GetRootChannel(channelId); + + if (channel.GuildId != null) + { + var userId = await cache.GetOwnUser(); + var member = await cache.TryGetSelfMember(channel.GuildId.Value); + return await cache.PermissionsFor(channelId, userId, member); + } + + return PermissionSet.Dm; + } +} \ No newline at end of file diff --git a/Myriad/Cache/IDiscordCache.cs b/Myriad/Cache/IDiscordCache.cs new file mode 100644 index 00000000..a9ecf4de --- /dev/null +++ b/Myriad/Cache/IDiscordCache.cs @@ -0,0 +1,29 @@ +using Myriad.Types; + +namespace Myriad.Cache; + +public interface IDiscordCache +{ + public ValueTask SaveOwnUser(ulong userId); + public ValueTask SaveGuild(Guild guild); + public ValueTask SaveChannel(Channel channel); + public ValueTask SaveUser(User user); + public ValueTask SaveSelfMember(ulong guildId, GuildMemberPartial member); + public ValueTask SaveRole(ulong guildId, Role role); + public ValueTask SaveDmChannelStub(ulong channelId); + + public ValueTask RemoveGuild(ulong guildId); + public ValueTask RemoveChannel(ulong channelId); + public ValueTask RemoveUser(ulong userId); + public ValueTask RemoveRole(ulong guildId, ulong roleId); + + public Task GetOwnUser(); + public Task TryGetGuild(ulong guildId); + public Task TryGetChannel(ulong channelId); + public Task TryGetUser(ulong userId); + public Task TryGetSelfMember(ulong guildId); + public Task TryGetRole(ulong roleId); + + public IAsyncEnumerable GetAllGuilds(); + public Task> GetGuildChannels(ulong guildId); +} \ No newline at end of file diff --git a/Myriad/Cache/MemoryDiscordCache.cs b/Myriad/Cache/MemoryDiscordCache.cs new file mode 100644 index 00000000..6a6d704b --- /dev/null +++ b/Myriad/Cache/MemoryDiscordCache.cs @@ -0,0 +1,187 @@ +using System.Collections.Concurrent; + +using Myriad.Types; + +namespace Myriad.Cache; + +public class MemoryDiscordCache: IDiscordCache +{ + private readonly ConcurrentDictionary _channels = new(); + private readonly ConcurrentDictionary _guildMembers = new(); + private readonly ConcurrentDictionary _guilds = new(); + private readonly ConcurrentDictionary _roles = new(); + private readonly ConcurrentDictionary _users = new(); + private ulong? _ownUserId { get; set; } + + public ValueTask SaveGuild(Guild guild) + { + if (!_guilds.ContainsKey(guild.Id)) + { + _guilds[guild.Id] = new CachedGuild(guild); + } + else + { + var channels = _guilds[guild.Id].Channels; + _guilds[guild.Id] = new CachedGuild(guild) + { + Channels = channels, + }; + } + + foreach (var role in guild.Roles) + // Don't call SaveRole because that updates guild state + // and we just got a brand new one :) + _roles[role.Id] = role; + + return default; + } + + public async ValueTask SaveChannel(Channel channel) + { + _channels[channel.Id] = channel; + + if (channel.GuildId != null && _guilds.TryGetValue(channel.GuildId.Value, out var guild)) + guild.Channels.TryAdd(channel.Id, true); + + if (channel.Recipients != null) + foreach (var recipient in channel.Recipients) + await SaveUser(recipient); + } + + public ValueTask SaveOwnUser(ulong userId) + { + // this (hopefully) never changes at runtime, so we skip out on re-assigning it + if (_ownUserId == null) + _ownUserId = userId; + + return default; + } + + public ValueTask SaveUser(User user) + { + _users[user.Id] = user; + return default; + } + + public ValueTask SaveSelfMember(ulong guildId, GuildMemberPartial member) + { + _guildMembers[guildId] = member; + return default; + } + + public ValueTask SaveRole(ulong guildId, Role role) + { + _roles[role.Id] = role; + + if (_guilds.TryGetValue(guildId, out var guild)) + { + // TODO: this code is stinky + var found = false; + for (var i = 0; i < guild.Guild.Roles.Length; i++) + { + if (guild.Guild.Roles[i].Id != role.Id) + continue; + + guild.Guild.Roles[i] = role; + found = true; + } + + if (!found) + _guilds[guildId] = guild with + { + Guild = guild.Guild with { Roles = guild.Guild.Roles.Concat(new[] { role }).ToArray() } + }; + } + + return default; + } + + public ValueTask SaveDmChannelStub(ulong channelId) + { + // Use existing channel object if present, otherwise add a stub + // We may get a message create before channel create and we want to have it saved + _channels.GetOrAdd(channelId, id => new Channel { Id = id, Type = Channel.ChannelType.Dm }); + return default; + } + + public ValueTask RemoveGuild(ulong guildId) + { + _guilds.TryRemove(guildId, out _); + return default; + } + + public ValueTask RemoveChannel(ulong channelId) + { + if (!_channels.TryRemove(channelId, out var channel)) + return default; + + if (channel.GuildId != null && _guilds.TryGetValue(channel.GuildId.Value, out var guild)) + guild.Channels.TryRemove(channel.Id, out _); + + return default; + } + + public ValueTask RemoveUser(ulong userId) + { + _users.TryRemove(userId, out _); + return default; + } + + public Task GetOwnUser() => Task.FromResult(_ownUserId!.Value); + + public ValueTask RemoveRole(ulong guildId, ulong roleId) + { + _roles.TryRemove(roleId, out _); + return default; + } + + public Task TryGetGuild(ulong guildId) + { + _guilds.TryGetValue(guildId, out var cg); + return Task.FromResult(cg?.Guild); + } + + public Task TryGetChannel(ulong channelId) + { + _channels.TryGetValue(channelId, out var channel); + return Task.FromResult(channel); + } + + public Task TryGetUser(ulong userId) + { + _users.TryGetValue(userId, out var user); + return Task.FromResult(user); + } + + public Task TryGetSelfMember(ulong guildId) + { + _guildMembers.TryGetValue(guildId, out var guildMember); + return Task.FromResult(guildMember); + } + + public Task TryGetRole(ulong roleId) + { + _roles.TryGetValue(roleId, out var role); + return Task.FromResult(role); + } + + public IAsyncEnumerable GetAllGuilds() + { + return _guilds.Values + .Select(g => g.Guild) + .ToAsyncEnumerable(); + } + + public Task> GetGuildChannels(ulong guildId) + { + if (!_guilds.TryGetValue(guildId, out var guild)) + throw new ArgumentException("Guild not found", nameof(guildId)); + + return Task.FromResult(guild.Channels.Keys.Select(c => _channels[c])); + } + + private record CachedGuild(Guild Guild) + { + public ConcurrentDictionary Channels { get; init; } = new(); + } +} \ No newline at end of file diff --git a/Myriad/Cache/RedisDiscordCache.cs b/Myriad/Cache/RedisDiscordCache.cs new file mode 100644 index 00000000..ffbcebdb --- /dev/null +++ b/Myriad/Cache/RedisDiscordCache.cs @@ -0,0 +1,347 @@ +using Google.Protobuf; + +using StackExchange.Redis; +using StackExchange.Redis.KeyspaceIsolation; + +using Serilog; + +using Myriad.Types; + +namespace Myriad.Cache; + +#pragma warning disable 4014 +public class RedisDiscordCache: IDiscordCache +{ + private readonly ILogger _logger; + public RedisDiscordCache(ILogger logger) + { + _logger = logger; + } + + private ConnectionMultiplexer _redis { get; set; } + private ulong _ownUserId { get; set; } + + public async Task InitAsync(string addr, ulong ownUserId) + { + _redis = await ConnectionMultiplexer.ConnectAsync(addr); + _ownUserId = ownUserId; + } + + private IDatabase db => _redis.GetDatabase().WithKeyPrefix("discord:"); + + public async ValueTask SaveGuild(Guild guild) + { + _logger.Verbose("Saving guild {GuildId} to redis", guild.Id); + + var g = new CachedGuild(); + g.Id = guild.Id; + g.Name = guild.Name; + g.OwnerId = guild.OwnerId; + g.PremiumTier = (int)guild.PremiumTier; + + var tr = db.CreateTransaction(); + + tr.HashSetAsync("guilds", guild.Id.HashWrapper(g)); + + foreach (var role in guild.Roles) + { + // Don't call SaveRole because that updates guild state + // and we just got a brand new one :) + // actually with redis it doesn't update guild state, but we're still doing it here because transaction + tr.HashSetAsync("roles", role.Id.HashWrapper(new CachedRole() + { + Id = role.Id, + Name = role.Name, + Position = role.Position, + Permissions = (ulong)role.Permissions, + Mentionable = role.Mentionable, + })); + + tr.HashSetAsync($"guild_roles:{guild.Id}", role.Id, true, When.NotExists); + } + + await tr.ExecuteAsync(); + } + + public async ValueTask SaveChannel(Channel channel) + { + _logger.Verbose("Saving channel {ChannelId} to redis", channel.Id); + + await db.HashSetAsync("channels", channel.Id.HashWrapper(channel.ToProtobuf())); + + if (channel.GuildId != null) + await db.HashSetAsync($"guild_channels:{channel.GuildId.Value}", channel.Id, true, When.NotExists); + + // todo: use a transaction for this? + if (channel.Recipients != null) + foreach (var recipient in channel.Recipients) + await SaveUser(recipient); + } + + public ValueTask SaveOwnUser(ulong userId) + { + // we get the own user ID in InitAsync, so no need to save it here + return default; + } + + public async ValueTask SaveUser(User user) + { + _logger.Verbose("Saving user {UserId} to redis", user.Id); + + var u = new CachedUser() + { + Id = user.Id, + Username = user.Username, + Discriminator = user.Discriminator, + Bot = user.Bot, + }; + + if (user.Avatar != null) + u.Avatar = user.Avatar; + + await db.HashSetAsync("users", user.Id.HashWrapper(u)); + } + + public async ValueTask SaveSelfMember(ulong guildId, GuildMemberPartial member) + { + _logger.Verbose("Saving self member for guild {GuildId} to redis", guildId); + + var gm = new CachedGuildMember(); + foreach (var role in member.Roles) + gm.Roles.Add(role); + + await db.HashSetAsync("members", guildId.HashWrapper(gm)); + } + + public async ValueTask SaveRole(ulong guildId, Myriad.Types.Role role) + { + _logger.Verbose("Saving role {RoleId} in {GuildId} to redis", role.Id, guildId); + + await db.HashSetAsync("roles", role.Id.HashWrapper(new CachedRole() + { + Id = role.Id, + Mentionable = role.Mentionable, + Name = role.Name, + Permissions = (ulong)role.Permissions, + Position = role.Position, + })); + + await db.HashSetAsync($"guild_roles:{guildId}", role.Id, true, When.NotExists); + } + + public async ValueTask SaveDmChannelStub(ulong channelId) + { + // Use existing channel object if present, otherwise add a stub + // We may get a message create before channel create and we want to have it saved + + if (await TryGetChannel(channelId) == null) + await db.HashSetAsync("channels", channelId.HashWrapper(new CachedChannel() + { + Id = channelId, + Type = (int)Channel.ChannelType.Dm, + })); + } + + public async ValueTask RemoveGuild(ulong guildId) + => await db.HashDeleteAsync("guilds", guildId); + + public async ValueTask RemoveChannel(ulong channelId) + { + var oldChannel = await TryGetChannel(channelId); + + if (oldChannel == null) + return; + + await db.HashDeleteAsync("channels", channelId); + + if (oldChannel.GuildId != null) + await db.HashDeleteAsync($"guild_channels:{oldChannel.GuildId.Value}", oldChannel.Id); + } + + public async ValueTask RemoveUser(ulong userId) + => await db.HashDeleteAsync("users", userId); + + // todo: try getting this from redis if we don't have it yet + public Task GetOwnUser() => Task.FromResult(_ownUserId); + + public async ValueTask RemoveRole(ulong guildId, ulong roleId) + { + await db.HashDeleteAsync("roles", roleId); + await db.HashDeleteAsync($"guild_roles:{guildId}", roleId); + } + + public async Task TryGetGuild(ulong guildId) + { + var redisGuild = await db.HashGetAsync("guilds", guildId); + if (redisGuild.IsNullOrEmpty) + return null; + + var guild = ((byte[])redisGuild).Unmarshal(); + + var redisRoles = await db.HashGetAllAsync($"guild_roles:{guildId}"); + + // todo: put this in a transaction or something + var roles = await Task.WhenAll(redisRoles.Select(r => TryGetRole((ulong)r.Name))); + +#pragma warning disable 8619 + return guild.FromProtobuf() with { Roles = roles }; +#pragma warning restore 8619 + } + + public async Task TryGetChannel(ulong channelId) + { + var redisChannel = await db.HashGetAsync("channels", channelId); + if (redisChannel.IsNullOrEmpty) + return null; + + return ((byte[])redisChannel).Unmarshal().FromProtobuf(); + } + + public async Task TryGetUser(ulong userId) + { + var redisUser = await db.HashGetAsync("users", userId); + if (redisUser.IsNullOrEmpty) + return null; + + return ((byte[])redisUser).Unmarshal().FromProtobuf(); + } + + public async Task TryGetSelfMember(ulong guildId) + { + var redisMember = await db.HashGetAsync("members", guildId); + if (redisMember.IsNullOrEmpty) + return null; + + return new GuildMemberPartial() + { + Roles = ((byte[])redisMember).Unmarshal().Roles.ToArray() + }; + } + + public async Task TryGetRole(ulong roleId) + { + var redisRole = await db.HashGetAsync("roles", roleId); + if (redisRole.IsNullOrEmpty) + return null; + + var role = ((byte[])redisRole).Unmarshal(); + + return new Myriad.Types.Role() + { + Id = role.Id, + Name = role.Name, + Position = role.Position, + Permissions = (PermissionSet)role.Permissions, + Mentionable = role.Mentionable, + }; + } + + public IAsyncEnumerable GetAllGuilds() + { + // return _guilds.Values + // .Select(g => g.Guild) + // .ToAsyncEnumerable(); + return new Guild[] { }.ToAsyncEnumerable(); + } + + public async Task> GetGuildChannels(ulong guildId) + { + var redisChannels = await db.HashGetAllAsync($"guild_channels:{guildId}"); + if (redisChannels.Length == 0) + throw new ArgumentException("Guild not found", nameof(guildId)); + +#pragma warning disable 8619 + return await Task.WhenAll(redisChannels.Select(c => TryGetChannel((ulong)c.Name))); +#pragma warning restore 8619 + } +} + +internal static class CacheProtoExt +{ + public static Guild FromProtobuf(this CachedGuild guild) + => new Guild() + { + Id = guild.Id, + Name = guild.Name, + OwnerId = guild.OwnerId, + PremiumTier = (PremiumTier)guild.PremiumTier, + }; + + public static CachedChannel ToProtobuf(this Channel channel) + { + var c = new CachedChannel(); + c.Id = channel.Id; + c.Type = (int)channel.Type; + if (channel.Position != null) + c.Position = channel.Position.Value; + c.Name = channel.Name; + if (channel.PermissionOverwrites != null) + foreach (var overwrite in channel.PermissionOverwrites) + c.PermissionOverwrites.Add(new Overwrite() + { + Id = overwrite.Id, + Type = (int)overwrite.Type, + Allow = (ulong)overwrite.Allow, + Deny = (ulong)overwrite.Deny, + }); + if (channel.GuildId != null) + c.GuildId = channel.GuildId.Value; + + return c; + } + + public static Channel FromProtobuf(this CachedChannel channel) + => new Channel() + { + Id = channel.Id, + Type = (Channel.ChannelType)channel.Type, + Position = channel.Position, + Name = channel.Name, + PermissionOverwrites = channel.PermissionOverwrites + .Select(x => new Channel.Overwrite() + { + Id = x.Id, + Type = (Channel.OverwriteType)x.Type, + Allow = (PermissionSet)x.Allow, + Deny = (PermissionSet)x.Deny, + }).ToArray(), + GuildId = channel.HasGuildId ? channel.GuildId : null, + ParentId = channel.HasParentId ? channel.ParentId : null, + }; + + public static User FromProtobuf(this CachedUser user) + => new User() + { + Id = user.Id, + Username = user.Username, + Discriminator = user.Discriminator, + Avatar = user.HasAvatar ? user.Avatar : null, + Bot = user.Bot, + }; +} + +internal static class RedisExt +{ + // convenience method + public static HashEntry[] HashWrapper(this ulong key, T value) where T : IMessage + => new[] { new HashEntry(key, value.ToByteArray()) }; +} + +public static class ProtobufExt +{ + private static Dictionary _parser = new(); + + public static byte[] Marshal(this IMessage message) => message.ToByteArray(); + + public static T Unmarshal(this byte[] message) where T : IMessage, new() + { + var type = typeof(T).ToString(); + if (_parser.ContainsKey(type)) + return (T)_parser[type].ParseFrom(message); + else + { + _parser.Add(type, new MessageParser(() => new T())); + return Unmarshal(message); + } + } +} \ No newline at end of file diff --git a/Myriad/Extensions/CacheExtensions.cs b/Myriad/Extensions/CacheExtensions.cs new file mode 100644 index 00000000..17660002 --- /dev/null +++ b/Myriad/Extensions/CacheExtensions.cs @@ -0,0 +1,70 @@ +using Myriad.Cache; +using Myriad.Rest; +using Myriad.Types; + +namespace Myriad.Extensions; + +public static class CacheExtensions +{ + public static async Task GetGuild(this IDiscordCache cache, ulong guildId) + { + if (!(await cache.TryGetGuild(guildId) is Guild guild)) + throw new KeyNotFoundException($"Guild {guildId} not found in cache"); + return guild; + } + + public static async Task GetChannel(this IDiscordCache cache, ulong channelId) + { + if (!(await cache.TryGetChannel(channelId) is Channel channel)) + throw new KeyNotFoundException($"Channel {channelId} not found in cache"); + return channel; + } + + public static async Task GetUser(this IDiscordCache cache, ulong userId) + { + if (!(await cache.TryGetUser(userId) is User user)) + throw new KeyNotFoundException($"User {userId} not found in cache"); + return user; + } + + public static async Task GetRole(this IDiscordCache cache, ulong roleId) + { + if (!(await cache.TryGetRole(roleId) is Role role)) + throw new KeyNotFoundException($"Role {roleId} not found in cache"); + return role; + } + + public static async ValueTask GetOrFetchUser(this IDiscordCache cache, DiscordApiClient rest, + ulong userId) + { + if (await cache.TryGetUser(userId) is User cacheUser) + return cacheUser; + + var restUser = await rest.GetUser(userId); + if (restUser != null) + await cache.SaveUser(restUser); + return restUser; + } + + public static async ValueTask GetOrFetchChannel(this IDiscordCache cache, DiscordApiClient rest, + ulong channelId) + { + if (await cache.TryGetChannel(channelId) is { } cacheChannel) + return cacheChannel; + + var restChannel = await rest.GetChannel(channelId); + if (restChannel != null) + await cache.SaveChannel(restChannel); + return restChannel; + } + + public static async Task GetRootChannel(this IDiscordCache cache, ulong channelOrThread) + { + var channel = await cache.GetChannel(channelOrThread); + if (!channel.IsThread()) + return channel; + + var parent = await cache.GetChannel(channel.ParentId!.Value); + return parent; + } +} \ No newline at end of file diff --git a/Myriad/Extensions/ChannelExtensions.cs b/Myriad/Extensions/ChannelExtensions.cs new file mode 100644 index 00000000..1512f32e --- /dev/null +++ b/Myriad/Extensions/ChannelExtensions.cs @@ -0,0 +1,15 @@ +using Myriad.Types; + +namespace Myriad.Extensions; + +public static class ChannelExtensions +{ + public static string Mention(this Channel channel) => $"<#{channel.Id}>"; + + public static bool IsThread(this Channel channel) => channel.Type.IsThread(); + + public static bool IsThread(this Channel.ChannelType type) => + type is Channel.ChannelType.GuildPublicThread + or Channel.ChannelType.GuildPrivateThread + or Channel.ChannelType.GuildNewsThread; +} \ No newline at end of file diff --git a/Myriad/Extensions/GuildExtensions.cs b/Myriad/Extensions/GuildExtensions.cs new file mode 100644 index 00000000..151e6e50 --- /dev/null +++ b/Myriad/Extensions/GuildExtensions.cs @@ -0,0 +1,21 @@ +using Myriad.Types; + +namespace Myriad.Extensions; + +public static class GuildExtensions +{ + public static int FileSizeLimit(this Guild guild) + { + switch (guild.PremiumTier) + { + default: + case PremiumTier.NONE: + case PremiumTier.TIER_1: + return 8; + case PremiumTier.TIER_2: + return 50; + case PremiumTier.TIER_3: + return 100; + } + } +} \ No newline at end of file diff --git a/Myriad/Extensions/MessageExtensions.cs b/Myriad/Extensions/MessageExtensions.cs new file mode 100644 index 00000000..93498484 --- /dev/null +++ b/Myriad/Extensions/MessageExtensions.cs @@ -0,0 +1,13 @@ +using Myriad.Gateway; +using Myriad.Types; + +namespace Myriad.Extensions; + +public static class MessageExtensions +{ + public static string JumpLink(this Message msg) => + $"https://discord.com/channels/{msg.GuildId}/{msg.ChannelId}/{msg.Id}"; + + public static string JumpLink(this MessageReactionAddEvent msg) => + $"https://discord.com/channels/{msg.GuildId}/{msg.ChannelId}/{msg.MessageId}"; +} \ No newline at end of file diff --git a/Myriad/Extensions/PermissionExtensions.cs b/Myriad/Extensions/PermissionExtensions.cs new file mode 100644 index 00000000..8b718dce --- /dev/null +++ b/Myriad/Extensions/PermissionExtensions.cs @@ -0,0 +1,167 @@ +using Myriad.Cache; +using Myriad.Gateway; +using Myriad.Types; + +namespace Myriad.Extensions; + +public static class PermissionExtensions +{ + private const PermissionSet NeedsViewChannel = + PermissionSet.SendMessages | + PermissionSet.SendTtsMessages | + PermissionSet.ManageMessages | + PermissionSet.EmbedLinks | + PermissionSet.AttachFiles | + PermissionSet.ReadMessageHistory | + PermissionSet.MentionEveryone | + PermissionSet.UseExternalEmojis | + PermissionSet.AddReactions | + PermissionSet.Connect | + PermissionSet.Speak | + PermissionSet.MuteMembers | + PermissionSet.DeafenMembers | + PermissionSet.MoveMembers | + PermissionSet.UseVad | + PermissionSet.Stream | + PermissionSet.PrioritySpeaker; + + private const PermissionSet NeedsSendMessages = + PermissionSet.MentionEveryone | + PermissionSet.SendTtsMessages | + PermissionSet.AttachFiles | + PermissionSet.EmbedLinks; + + public static Task PermissionsFor(this IDiscordCache cache, MessageCreateEvent message) => + PermissionsFor(cache, message.ChannelId, message.Author.Id, message.Member, message.WebhookId != null); + + public static Task + PermissionsFor(this IDiscordCache cache, ulong channelId, GuildMember member) => + PermissionsFor(cache, channelId, member.User.Id, member); + + public static async Task PermissionsFor(this IDiscordCache cache, ulong channelId, ulong userId, + GuildMemberPartial? member, bool isWebhook = false) + { + if (!(await cache.TryGetChannel(channelId) is Channel channel)) + // todo: handle channel not found better + return PermissionSet.Dm; + + if (channel.GuildId == null) + return PermissionSet.Dm; + + var rootChannel = await cache.GetRootChannel(channelId); + + var guild = await cache.GetGuild(channel.GuildId.Value); + + if (isWebhook) + return EveryonePermissions(guild); + + return PermissionsFor(guild, rootChannel, userId, member); + } + + public static PermissionSet EveryonePermissions(this Guild guild) => + guild.Roles.FirstOrDefault(r => r.Id == guild.Id)!.Permissions; + + public static PermissionSet EveryonePermissions(Guild guild, Channel channel) + { + if (channel.Type == Channel.ChannelType.Dm) + return PermissionSet.Dm; + + var defaultPermissions = guild.EveryonePermissions(); + var overwrite = channel.PermissionOverwrites?.FirstOrDefault(r => r.Id == channel.GuildId); + if (overwrite == null) + return defaultPermissions; + + var perms = defaultPermissions; + perms &= ~overwrite.Deny; + perms |= overwrite.Allow; + + return perms; + } + + public static PermissionSet PermissionsFor(Guild guild, Channel channel, MessageCreateEvent msg) => + PermissionsFor(guild, channel, msg.Author.Id, msg.Member); + + public static PermissionSet PermissionsFor(Guild guild, Channel channel, ulong userId, + GuildMemberPartial? member) + { + if (channel.Type == Channel.ChannelType.Dm) + return PermissionSet.Dm; + + if (member == null) + // this happens with system (Discord platform-owned) users - they're not actually in the guild, so there is no member object. + return EveryonePermissions(guild); + + var perms = GuildPermissions(guild, userId, member.Roles); + perms = ApplyChannelOverwrites(perms, channel, userId, member.Roles); + + if ((perms & PermissionSet.Administrator) == PermissionSet.Administrator) + return PermissionSet.All; + + if ((perms & PermissionSet.ViewChannel) == 0) + perms &= ~NeedsViewChannel; + + if ((perms & PermissionSet.SendMessages) == 0) + perms &= ~NeedsSendMessages; + + return perms; + } + + public static PermissionSet GuildPermissions(this Guild guild, ulong userId, ICollection roleIds) + { + if (guild.OwnerId == userId) + return PermissionSet.All; + + var perms = PermissionSet.None; + foreach (var role in guild.Roles) + if (role.Id == guild.Id || roleIds.Contains(role.Id)) + perms |= role.Permissions; + + if (perms.HasFlag(PermissionSet.Administrator)) + return PermissionSet.All; + + return perms; + } + + public static PermissionSet ApplyChannelOverwrites(PermissionSet perms, Channel channel, ulong userId, + ICollection roleIds) + { + if (channel.PermissionOverwrites == null) + return perms; + + var everyoneDeny = PermissionSet.None; + var everyoneAllow = PermissionSet.None; + var roleDeny = PermissionSet.None; + var roleAllow = PermissionSet.None; + var userDeny = PermissionSet.None; + var userAllow = PermissionSet.None; + + foreach (var overwrite in channel.PermissionOverwrites) + switch (overwrite.Type) + { + case Channel.OverwriteType.Role when overwrite.Id == channel.GuildId: + everyoneDeny |= overwrite.Deny; + everyoneAllow |= overwrite.Allow; + break; + case Channel.OverwriteType.Role when roleIds.Contains(overwrite.Id): + roleDeny |= overwrite.Deny; + roleAllow |= overwrite.Allow; + break; + case Channel.OverwriteType.Member when overwrite.Id == userId: + userDeny |= overwrite.Deny; + userAllow |= overwrite.Allow; + break; + } + + perms &= ~everyoneDeny; + perms |= everyoneAllow; + perms &= ~roleDeny; + perms |= roleAllow; + perms &= ~userDeny; + perms |= userAllow; + return perms; + } + + public static string ToPermissionString(this PermissionSet perms) => + // TODO: clean string + perms.ToString(); +} \ No newline at end of file diff --git a/Myriad/Extensions/SnowflakeExtensions.cs b/Myriad/Extensions/SnowflakeExtensions.cs new file mode 100644 index 00000000..f5cc3a2a --- /dev/null +++ b/Myriad/Extensions/SnowflakeExtensions.cs @@ -0,0 +1,17 @@ +using Myriad.Types; + +namespace Myriad.Extensions; + +public static class SnowflakeExtensions +{ + public static readonly DateTimeOffset DiscordEpoch = new(2015, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static DateTimeOffset SnowflakeToTimestamp(ulong snowflake) => + DiscordEpoch + TimeSpan.FromMilliseconds(snowflake >> 22); + + public static DateTimeOffset Timestamp(this Message msg) => SnowflakeToTimestamp(msg.Id); + public static DateTimeOffset Timestamp(this Channel channel) => SnowflakeToTimestamp(channel.Id); + public static DateTimeOffset Timestamp(this Guild guild) => SnowflakeToTimestamp(guild.Id); + public static DateTimeOffset Timestamp(this Webhook webhook) => SnowflakeToTimestamp(webhook.Id); + public static DateTimeOffset Timestamp(this User user) => SnowflakeToTimestamp(user.Id); +} \ No newline at end of file diff --git a/Myriad/Extensions/UserExtensions.cs b/Myriad/Extensions/UserExtensions.cs new file mode 100644 index 00000000..f908958e --- /dev/null +++ b/Myriad/Extensions/UserExtensions.cs @@ -0,0 +1,11 @@ +using Myriad.Types; + +namespace Myriad.Extensions; + +public static class UserExtensions +{ + public static string Mention(this User user) => $"<@{user.Id}>"; + + public static string AvatarUrl(this User user, string? format = "png", int? size = 128) => + $"https://cdn.discordapp.com/avatars/{user.Id}/{user.Avatar}.{format}?size={size}"; +} \ No newline at end of file diff --git a/Myriad/Gateway/Cluster.cs b/Myriad/Gateway/Cluster.cs new file mode 100644 index 00000000..be5640a5 --- /dev/null +++ b/Myriad/Gateway/Cluster.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; + +using Myriad.Gateway.Limit; +using Myriad.Types; + +using Serilog; + +using StackExchange.Redis; + +namespace Myriad.Gateway; + +public class Cluster +{ + private readonly GatewaySettings _gatewaySettings; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _shards = new(); + private IGatewayRatelimiter? _ratelimiter; + + public GatewayStatusUpdate DiscordPresence { get; set; } + + public Cluster(GatewaySettings gatewaySettings, ILogger logger) + { + _gatewaySettings = gatewaySettings; + _logger = logger.ForContext(); + } + + public Func? EventReceived { get; set; } + + public IReadOnlyDictionary Shards => _shards; + public event Action? ShardCreated; + + public async Task Start(GatewayInfo.Bot info, ConnectionMultiplexer? conn = null) + { + await Start(info.Url, 0, info.Shards - 1, info.Shards, info.SessionStartLimit.MaxConcurrency, conn); + } + + public async Task Start(string url, int shardMin, int shardMax, int shardTotal, int recommendedConcurrency, ConnectionMultiplexer? conn = null) + { + _ratelimiter = GetRateLimiter(recommendedConcurrency, conn); + + var shardCount = shardMax - shardMin + 1; + _logger.Information("Starting {ShardCount} of {ShardTotal} shards (#{ShardMin}-#{ShardMax}) at {Url}", + shardCount, shardTotal, shardMin, shardMax, url); + for (var i = shardMin; i <= shardMax; i++) + CreateAndAddShard(url, new ShardInfo(i, shardTotal)); + + await StartShards(); + } + + private async Task StartShards() + { + _logger.Information("Connecting shards..."); + foreach (var shard in _shards.Values) + await shard.Start(); + } + + private void CreateAndAddShard(string url, ShardInfo shardInfo) + { + var shard = new Shard(_gatewaySettings, shardInfo, _ratelimiter!, url, _logger, DiscordPresence); + shard.OnEventReceived += evt => OnShardEventReceived(shard, evt); + _shards[shardInfo.ShardId] = shard; + + ShardCreated?.Invoke(shard); + } + + private async Task OnShardEventReceived(Shard shard, IGatewayEvent evt) + { + if (EventReceived != null) + await EventReceived(shard, evt); + } + + private int GetActualShardConcurrency(int recommendedConcurrency) + { + if (_gatewaySettings.MaxShardConcurrency == null) + return recommendedConcurrency; + + return Math.Min(_gatewaySettings.MaxShardConcurrency.Value, recommendedConcurrency); + } + + private IGatewayRatelimiter GetRateLimiter(int recommendedConcurrency, ConnectionMultiplexer? conn = null) + { + var concurrency = GetActualShardConcurrency(recommendedConcurrency); + + if (_gatewaySettings.UseRedisRatelimiter) + { + if (conn != null) + return new RedisRatelimiter(_logger, conn, concurrency); + else + _logger.Warning("Tried to get Redis ratelimiter but connection is null! Continuing with local ratelimiter."); + } + + if (_gatewaySettings.GatewayQueueUrl != null) + return new TwilightGatewayRatelimiter(_logger, _gatewaySettings.GatewayQueueUrl); + + return new LocalGatewayRatelimiter(_logger, concurrency); + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ChannelCreateEvent.cs b/Myriad/Gateway/Events/ChannelCreateEvent.cs new file mode 100644 index 00000000..b8b8ab30 --- /dev/null +++ b/Myriad/Gateway/Events/ChannelCreateEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record ChannelCreateEvent: Channel, IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/ChannelDeleteEvent.cs b/Myriad/Gateway/Events/ChannelDeleteEvent.cs new file mode 100644 index 00000000..9dec3e6a --- /dev/null +++ b/Myriad/Gateway/Events/ChannelDeleteEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record ChannelDeleteEvent: Channel, IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/ChannelUpdateEvent.cs b/Myriad/Gateway/Events/ChannelUpdateEvent.cs new file mode 100644 index 00000000..dd219cef --- /dev/null +++ b/Myriad/Gateway/Events/ChannelUpdateEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record ChannelUpdateEvent: Channel, IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildCreateEvent.cs b/Myriad/Gateway/Events/GuildCreateEvent.cs new file mode 100644 index 00000000..f2d96f55 --- /dev/null +++ b/Myriad/Gateway/Events/GuildCreateEvent.cs @@ -0,0 +1,10 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record GuildCreateEvent: Guild, IGatewayEvent +{ + public Channel[] Channels { get; init; } + public GuildMember[] Members { get; init; } + public Channel[] Threads { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildDeleteEvent.cs b/Myriad/Gateway/Events/GuildDeleteEvent.cs new file mode 100644 index 00000000..44e90ff9 --- /dev/null +++ b/Myriad/Gateway/Events/GuildDeleteEvent.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record GuildDeleteEvent(ulong Id, bool Unavailable): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildMemberAddEvent.cs b/Myriad/Gateway/Events/GuildMemberAddEvent.cs new file mode 100644 index 00000000..489b90e5 --- /dev/null +++ b/Myriad/Gateway/Events/GuildMemberAddEvent.cs @@ -0,0 +1,8 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record GuildMemberAddEvent: GuildMember, IGatewayEvent +{ + public ulong GuildId { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildMemberRemoveEvent.cs b/Myriad/Gateway/Events/GuildMemberRemoveEvent.cs new file mode 100644 index 00000000..5b57635c --- /dev/null +++ b/Myriad/Gateway/Events/GuildMemberRemoveEvent.cs @@ -0,0 +1,9 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public class GuildMemberRemoveEvent: IGatewayEvent +{ + public ulong GuildId { get; init; } + public User User { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildMemberUpdateEvent.cs b/Myriad/Gateway/Events/GuildMemberUpdateEvent.cs new file mode 100644 index 00000000..c9c2620e --- /dev/null +++ b/Myriad/Gateway/Events/GuildMemberUpdateEvent.cs @@ -0,0 +1,8 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record GuildMemberUpdateEvent: GuildMember, IGatewayEvent +{ + public ulong GuildId { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildRoleCreateEvent.cs b/Myriad/Gateway/Events/GuildRoleCreateEvent.cs new file mode 100644 index 00000000..bd9272e8 --- /dev/null +++ b/Myriad/Gateway/Events/GuildRoleCreateEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record GuildRoleCreateEvent(ulong GuildId, Role Role): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildRoleDeleteEvent.cs b/Myriad/Gateway/Events/GuildRoleDeleteEvent.cs new file mode 100644 index 00000000..79aa3ade --- /dev/null +++ b/Myriad/Gateway/Events/GuildRoleDeleteEvent.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record GuildRoleDeleteEvent(ulong GuildId, ulong RoleId): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildRoleUpdateEvent.cs b/Myriad/Gateway/Events/GuildRoleUpdateEvent.cs new file mode 100644 index 00000000..7d028e50 --- /dev/null +++ b/Myriad/Gateway/Events/GuildRoleUpdateEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record GuildRoleUpdateEvent(ulong GuildId, Role Role): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildUpdateEvent.cs b/Myriad/Gateway/Events/GuildUpdateEvent.cs new file mode 100644 index 00000000..9d639992 --- /dev/null +++ b/Myriad/Gateway/Events/GuildUpdateEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record GuildUpdateEvent: Guild, IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/IGatewayEvent.cs b/Myriad/Gateway/Events/IGatewayEvent.cs new file mode 100644 index 00000000..2593c6a5 --- /dev/null +++ b/Myriad/Gateway/Events/IGatewayEvent.cs @@ -0,0 +1,35 @@ +namespace Myriad.Gateway; + +public interface IGatewayEvent +{ + public static readonly Dictionary EventTypes = new() + { + { "READY", typeof(ReadyEvent) }, + { "RESUMED", typeof(ResumedEvent) }, + { "GUILD_CREATE", typeof(GuildCreateEvent) }, + { "GUILD_UPDATE", typeof(GuildUpdateEvent) }, + { "GUILD_DELETE", typeof(GuildDeleteEvent) }, + { "GUILD_MEMBER_ADD", typeof(GuildMemberAddEvent) }, + { "GUILD_MEMBER_REMOVE", typeof(GuildMemberRemoveEvent) }, + { "GUILD_MEMBER_UPDATE", typeof(GuildMemberUpdateEvent) }, + { "GUILD_ROLE_CREATE", typeof(GuildRoleCreateEvent) }, + { "GUILD_ROLE_UPDATE", typeof(GuildRoleUpdateEvent) }, + { "GUILD_ROLE_DELETE", typeof(GuildRoleDeleteEvent) }, + { "CHANNEL_CREATE", typeof(ChannelCreateEvent) }, + { "CHANNEL_UPDATE", typeof(ChannelUpdateEvent) }, + { "CHANNEL_DELETE", typeof(ChannelDeleteEvent) }, + { "THREAD_CREATE", typeof(ThreadCreateEvent) }, + { "THREAD_UPDATE", typeof(ThreadUpdateEvent) }, + { "THREAD_DELETE", typeof(ThreadDeleteEvent) }, + { "THREAD_LIST_SYNC", typeof(ThreadListSyncEvent) }, + { "MESSAGE_CREATE", typeof(MessageCreateEvent) }, + { "MESSAGE_UPDATE", typeof(MessageUpdateEvent) }, + { "MESSAGE_DELETE", typeof(MessageDeleteEvent) }, + { "MESSAGE_DELETE_BULK", typeof(MessageDeleteBulkEvent) }, + { "MESSAGE_REACTION_ADD", typeof(MessageReactionAddEvent) }, + { "MESSAGE_REACTION_REMOVE", typeof(MessageReactionRemoveEvent) }, + { "MESSAGE_REACTION_REMOVE_ALL", typeof(MessageReactionRemoveAllEvent) }, + { "MESSAGE_REACTION_REMOVE_EMOJI", typeof(MessageReactionRemoveEmojiEvent) }, + { "INTERACTION_CREATE", typeof(InteractionCreateEvent) } + }; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/InteractionCreateEvent.cs b/Myriad/Gateway/Events/InteractionCreateEvent.cs new file mode 100644 index 00000000..350e1883 --- /dev/null +++ b/Myriad/Gateway/Events/InteractionCreateEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record InteractionCreateEvent: Interaction, IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageCreateEvent.cs b/Myriad/Gateway/Events/MessageCreateEvent.cs new file mode 100644 index 00000000..265684a7 --- /dev/null +++ b/Myriad/Gateway/Events/MessageCreateEvent.cs @@ -0,0 +1,8 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record MessageCreateEvent: Message, IGatewayEvent +{ + public GuildMemberPartial? Member { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageDeleteBulkEvent.cs b/Myriad/Gateway/Events/MessageDeleteBulkEvent.cs new file mode 100644 index 00000000..1f99a487 --- /dev/null +++ b/Myriad/Gateway/Events/MessageDeleteBulkEvent.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record MessageDeleteBulkEvent(ulong[] Ids, ulong ChannelId, ulong? GuildId): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageDeleteEvent.cs b/Myriad/Gateway/Events/MessageDeleteEvent.cs new file mode 100644 index 00000000..511fbdbc --- /dev/null +++ b/Myriad/Gateway/Events/MessageDeleteEvent.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record MessageDeleteEvent(ulong Id, ulong ChannelId, ulong? GuildId): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageReactionAddEvent.cs b/Myriad/Gateway/Events/MessageReactionAddEvent.cs new file mode 100644 index 00000000..d9a26f9e --- /dev/null +++ b/Myriad/Gateway/Events/MessageReactionAddEvent.cs @@ -0,0 +1,7 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record MessageReactionAddEvent(ulong UserId, ulong ChannelId, ulong MessageId, ulong? GuildId, + GuildMember? Member, + Emoji Emoji): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs b/Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs new file mode 100644 index 00000000..55d94247 --- /dev/null +++ b/Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record MessageReactionRemoveAllEvent(ulong ChannelId, ulong MessageId, ulong? GuildId): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs b/Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs new file mode 100644 index 00000000..861c3a56 --- /dev/null +++ b/Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record MessageReactionRemoveEmojiEvent + (ulong ChannelId, ulong MessageId, ulong? GuildId, Emoji Emoji): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageReactionRemoveEvent.cs b/Myriad/Gateway/Events/MessageReactionRemoveEvent.cs new file mode 100644 index 00000000..a5e9b043 --- /dev/null +++ b/Myriad/Gateway/Events/MessageReactionRemoveEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record MessageReactionRemoveEvent + (ulong UserId, ulong ChannelId, ulong MessageId, ulong? GuildId, Emoji Emoji): IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageUpdateEvent.cs b/Myriad/Gateway/Events/MessageUpdateEvent.cs new file mode 100644 index 00000000..2e918fb8 --- /dev/null +++ b/Myriad/Gateway/Events/MessageUpdateEvent.cs @@ -0,0 +1,15 @@ +using Myriad.Types; +using Myriad.Utils; + +namespace Myriad.Gateway; + +public record MessageUpdateEvent(ulong Id, ulong ChannelId): IGatewayEvent +{ + public Optional Content { get; init; } + public Optional Author { get; init; } + public Optional Member { get; init; } + public Optional Attachments { get; init; } + + public Optional GuildId { get; init; } + // TODO: lots of partials +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ReadyEvent.cs b/Myriad/Gateway/Events/ReadyEvent.cs new file mode 100644 index 00000000..781962a2 --- /dev/null +++ b/Myriad/Gateway/Events/ReadyEvent.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +using Myriad.Types; + +namespace Myriad.Gateway; + +public record ReadyEvent: IGatewayEvent +{ + [JsonPropertyName("v")] public int Version { get; init; } + public User User { get; init; } + public string SessionId { get; init; } + public ShardInfo? Shard { get; init; } + public ApplicationPartial Application { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ResumedEvent.cs b/Myriad/Gateway/Events/ResumedEvent.cs new file mode 100644 index 00000000..b6264380 --- /dev/null +++ b/Myriad/Gateway/Events/ResumedEvent.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record ResumedEvent: IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/ThreadCreateEvent.cs b/Myriad/Gateway/Events/ThreadCreateEvent.cs new file mode 100644 index 00000000..4a89c765 --- /dev/null +++ b/Myriad/Gateway/Events/ThreadCreateEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record ThreadCreateEvent: Channel, IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/Events/ThreadDeleteEvent.cs b/Myriad/Gateway/Events/ThreadDeleteEvent.cs new file mode 100644 index 00000000..4b2dd0be --- /dev/null +++ b/Myriad/Gateway/Events/ThreadDeleteEvent.cs @@ -0,0 +1,11 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record ThreadDeleteEvent: IGatewayEvent +{ + public ulong Id { get; init; } + public ulong? GuildId { get; init; } + public ulong? ParentId { get; init; } + public Channel.ChannelType Type { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ThreadListSyncEvent.cs b/Myriad/Gateway/Events/ThreadListSyncEvent.cs new file mode 100644 index 00000000..989e7175 --- /dev/null +++ b/Myriad/Gateway/Events/ThreadListSyncEvent.cs @@ -0,0 +1,10 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record ThreadListSyncEvent: IGatewayEvent +{ + public ulong GuildId { get; init; } + public ulong[]? ChannelIds { get; init; } + public Channel[] Threads { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ThreadUpdateEvent.cs b/Myriad/Gateway/Events/ThreadUpdateEvent.cs new file mode 100644 index 00000000..81a7d2b3 --- /dev/null +++ b/Myriad/Gateway/Events/ThreadUpdateEvent.cs @@ -0,0 +1,5 @@ +using Myriad.Types; + +namespace Myriad.Gateway; + +public record ThreadUpdateEvent: Channel, IGatewayEvent; \ No newline at end of file diff --git a/Myriad/Gateway/GatewayCloseException.cs b/Myriad/Gateway/GatewayCloseException.cs new file mode 100644 index 00000000..4a4d2276 --- /dev/null +++ b/Myriad/Gateway/GatewayCloseException.cs @@ -0,0 +1,32 @@ +namespace Myriad.Gateway; + +// TODO: unused? +public class GatewayCloseException: Exception +{ + public GatewayCloseException(int closeCode, string closeReason) : base($"{closeCode}: {closeReason}") + { + CloseCode = closeCode; + CloseReason = closeReason; + } + + public int CloseCode { get; } + public string CloseReason { get; } +} + +public class GatewayCloseCode +{ + public const int UnknownError = 4000; + public const int UnknownOpcode = 4001; + public const int DecodeError = 4002; + public const int NotAuthenticated = 4003; + public const int AuthenticationFailed = 4004; + public const int AlreadyAuthenticated = 4005; + public const int InvalidSeq = 4007; + public const int RateLimited = 4008; + public const int SessionTimedOut = 4009; + public const int InvalidShard = 4010; + public const int ShardingRequired = 4011; + public const int InvalidApiVersion = 4012; + public const int InvalidIntent = 4013; + public const int DisallowedIntent = 4014; +} \ No newline at end of file diff --git a/Myriad/Gateway/GatewayIntent.cs b/Myriad/Gateway/GatewayIntent.cs new file mode 100644 index 00000000..9739413d --- /dev/null +++ b/Myriad/Gateway/GatewayIntent.cs @@ -0,0 +1,22 @@ +namespace Myriad.Gateway; + +[Flags] +public enum GatewayIntent +{ + Guilds = 1 << 0, + GuildMembers = 1 << 1, + GuildBans = 1 << 2, + GuildEmojis = 1 << 3, + GuildIntegrations = 1 << 4, + GuildWebhooks = 1 << 5, + GuildInvites = 1 << 6, + GuildVoiceStates = 1 << 7, + GuildPresences = 1 << 8, + GuildMessages = 1 << 9, + GuildMessageReactions = 1 << 10, + GuildMessageTyping = 1 << 11, + DirectMessages = 1 << 12, + DirectMessageReactions = 1 << 13, + DirectMessageTyping = 1 << 14, + MessageContent = 1 << 15, +} \ No newline at end of file diff --git a/Myriad/Gateway/GatewayPacket.cs b/Myriad/Gateway/GatewayPacket.cs new file mode 100644 index 00000000..7c8628ff --- /dev/null +++ b/Myriad/Gateway/GatewayPacket.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace Myriad.Gateway; + +public record GatewayPacket +{ + [JsonPropertyName("op")] public GatewayOpcode Opcode { get; init; } + [JsonPropertyName("d")] public object? Payload { get; init; } + + [JsonPropertyName("s")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Sequence { get; init; } + + [JsonPropertyName("t")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EventType { get; init; } +} + +public enum GatewayOpcode +{ + Dispatch = 0, + Heartbeat = 1, + Identify = 2, + PresenceUpdate = 3, + VoiceStateUpdate = 4, + Resume = 6, + Reconnect = 7, + RequestGuildMembers = 8, + InvalidSession = 9, + Hello = 10, + HeartbeatAck = 11 +} \ No newline at end of file diff --git a/Myriad/Gateway/GatewaySettings.cs b/Myriad/Gateway/GatewaySettings.cs new file mode 100644 index 00000000..d4156e20 --- /dev/null +++ b/Myriad/Gateway/GatewaySettings.cs @@ -0,0 +1,10 @@ +namespace Myriad.Gateway; + +public record GatewaySettings +{ + public string Token { get; init; } + public GatewayIntent Intents { get; init; } + public bool UseRedisRatelimiter { get; init; } = false; + public int? MaxShardConcurrency { get; init; } + public string? GatewayQueueUrl { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Limit/IGatewayRatelimiter.cs b/Myriad/Gateway/Limit/IGatewayRatelimiter.cs new file mode 100644 index 00000000..2dbb79ee --- /dev/null +++ b/Myriad/Gateway/Limit/IGatewayRatelimiter.cs @@ -0,0 +1,6 @@ +namespace Myriad.Gateway.Limit; + +public interface IGatewayRatelimiter +{ + public Task Identify(int shard); +} \ No newline at end of file diff --git a/Myriad/Gateway/Limit/LocalGatewayRatelimiter.cs b/Myriad/Gateway/Limit/LocalGatewayRatelimiter.cs new file mode 100644 index 00000000..7042aa4a --- /dev/null +++ b/Myriad/Gateway/Limit/LocalGatewayRatelimiter.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; + +using Serilog; + +namespace Myriad.Gateway.Limit; + +public class LocalGatewayRatelimiter: IGatewayRatelimiter +{ + // docs specify 5 seconds, but we're actually throttling connections, not identify, so we need a bit of leeway + private static readonly TimeSpan BucketLength = TimeSpan.FromSeconds(6); + + private readonly ConcurrentDictionary> _buckets = new(); + private readonly ILogger _logger; + private readonly int _maxConcurrency; + + private Task? _refillTask; + + public LocalGatewayRatelimiter(ILogger logger, int maxConcurrency) + { + _logger = logger.ForContext(); + _maxConcurrency = maxConcurrency; + } + + public Task Identify(int shard) + { + var bucket = shard % _maxConcurrency; + var queue = _buckets.GetOrAdd(bucket, _ => new ConcurrentQueue()); + var tcs = new TaskCompletionSource(); + queue.Enqueue(tcs); + + ScheduleRefill(); + + return tcs.Task; + } + + private void ScheduleRefill() + { + if (_refillTask != null && !_refillTask.IsCompleted) + return; + + _refillTask?.Dispose(); + _refillTask = RefillTask(); + } + + private async Task RefillTask() + { + await Task.Delay(TimeSpan.FromMilliseconds(250)); + + while (true) + { + var isClear = true; + foreach (var (bucket, queue) in _buckets) + { + if (!queue.TryDequeue(out var tcs)) + continue; + + _logger.Debug( + "Allowing identify for bucket {BucketId} through ({QueueLength} left in bucket queue)", + bucket, queue.Count); + tcs.SetResult(); + isClear = false; + } + + if (isClear) + return; + + await Task.Delay(BucketLength); + } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Limit/RedisRatelimiter.cs b/Myriad/Gateway/Limit/RedisRatelimiter.cs new file mode 100644 index 00000000..80c82fb0 --- /dev/null +++ b/Myriad/Gateway/Limit/RedisRatelimiter.cs @@ -0,0 +1,46 @@ +using Serilog; + +using StackExchange.Redis; + +namespace Myriad.Gateway.Limit; + +public class RedisRatelimiter: IGatewayRatelimiter +{ + private readonly ILogger _logger; + private readonly ConnectionMultiplexer _redis; + + private int _concurrency { get; init; } + + // todo: these might need to be tweaked a little + private static TimeSpan expiry = TimeSpan.FromSeconds(6); + private static TimeSpan retryInterval = TimeSpan.FromMilliseconds(500); + + public RedisRatelimiter(ILogger logger, ConnectionMultiplexer redis, int concurrency) + { + _logger = logger.ForContext(); + _redis = redis; + _concurrency = concurrency; + } + + public async Task Identify(int shard) + { + _logger.Information("Shard {ShardId}: requesting identify from Redis", shard); + var key = "pluralkit:identify:" + (shard % _concurrency).ToString(); + await AcquireLock(key); + } + + public async Task AcquireLock(string key) + { + var conn = _redis.GetDatabase(); + + async Task TryAcquire() + { + _logger.Verbose("Trying to acquire lock on key {key} from Redis...", key); + await Task.Delay(retryInterval); + return await conn!.StringSetAsync(key, 0, expiry, When.NotExists); + } + + var acquired = false; + while (!acquired) acquired = await TryAcquire(); + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Limit/TwilightGatewayRatelimiter.cs b/Myriad/Gateway/Limit/TwilightGatewayRatelimiter.cs new file mode 100644 index 00000000..db7dc807 --- /dev/null +++ b/Myriad/Gateway/Limit/TwilightGatewayRatelimiter.cs @@ -0,0 +1,33 @@ +using Serilog; + +namespace Myriad.Gateway.Limit; + +public class TwilightGatewayRatelimiter: IGatewayRatelimiter +{ + private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + + private readonly ILogger _logger; + private readonly string _url; + + public TwilightGatewayRatelimiter(ILogger logger, string url) + { + _url = url; + _logger = logger.ForContext(); + } + + public async Task Identify(int shard) + { + while (true) + try + { + _logger.Information("Shard {ShardId}: Requesting identify at gateway queue {GatewayQueueUrl}", + shard, _url); + await _httpClient.GetAsync(_url + "?shard=" + shard); + return; + } + catch (TaskCanceledException) + { + _logger.Warning("Shard {ShardId}: Gateway queue timed out, retrying", shard); + } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayHello.cs b/Myriad/Gateway/Payloads/GatewayHello.cs new file mode 100644 index 00000000..db394d53 --- /dev/null +++ b/Myriad/Gateway/Payloads/GatewayHello.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record GatewayHello(int HeartbeatInterval); \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayIdentify.cs b/Myriad/Gateway/Payloads/GatewayIdentify.cs new file mode 100644 index 00000000..3dcbe13e --- /dev/null +++ b/Myriad/Gateway/Payloads/GatewayIdentify.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Myriad.Gateway; + +public record GatewayIdentify +{ + public string Token { get; init; } + public ConnectionProperties Properties { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Compress { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? LargeThreshold { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ShardInfo? Shard { get; init; } + + public GatewayIntent Intents { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public GatewayStatusUpdate? Presence { get; init; } + + public record ConnectionProperties + { + [JsonPropertyName("$os")] public string Os { get; init; } + [JsonPropertyName("$browser")] public string Browser { get; init; } + [JsonPropertyName("$device")] public string Device { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayResume.cs b/Myriad/Gateway/Payloads/GatewayResume.cs new file mode 100644 index 00000000..782c61cf --- /dev/null +++ b/Myriad/Gateway/Payloads/GatewayResume.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record GatewayResume(string Token, string SessionId, int Seq); \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayStatusUpdate.cs b/Myriad/Gateway/Payloads/GatewayStatusUpdate.cs new file mode 100644 index 00000000..51ab979c --- /dev/null +++ b/Myriad/Gateway/Payloads/GatewayStatusUpdate.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +using Myriad.Serialization; +using Myriad.Types; + +namespace Myriad.Gateway; + +public record GatewayStatusUpdate +{ + [JsonConverter(typeof(JsonSnakeCaseStringEnumConverter))] + public enum UserStatus + { + Online, + Dnd, + Idle, + Invisible, + Offline + } + + public ulong? Since { get; init; } + public Activity[]? Activities { get; init; } + public UserStatus Status { get; init; } + public bool Afk { get; init; } +} \ No newline at end of file diff --git a/Myriad/Gateway/Shard.cs b/Myriad/Gateway/Shard.cs new file mode 100644 index 00000000..ed7a3005 --- /dev/null +++ b/Myriad/Gateway/Shard.cs @@ -0,0 +1,215 @@ +using System.Net.WebSockets; +using System.Text.Json; + +using Myriad.Gateway.Limit; +using Myriad.Gateway.State; +using Myriad.Serialization; +using Myriad.Types; + +using Serilog; +using Serilog.Context; + +namespace Myriad.Gateway; + +public class Shard +{ + private const string LibraryName = "Myriad (for PluralKit)"; + + private readonly GatewaySettings _settings; + private readonly ShardInfo _info; + private readonly IGatewayRatelimiter _ratelimiter; + private readonly string _url; + private readonly ILogger _logger; + private readonly ShardStateManager _stateManager; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ShardConnection _conn; + + public int ShardId => _info.ShardId; + public ShardState State => _stateManager.State; + public TimeSpan? Latency => _stateManager.Latency; + public User? User => _stateManager.User; + public ApplicationPartial? Application => _stateManager.Application; + + // TODO: I wanna get rid of these or move them at some point + public event Func? OnEventReceived; + public event Action? HeartbeatReceived; + public event Action? SocketOpened; + public event Action? Resumed; + public event Action? Ready; + public event Action? SocketClosed; + + private TimeSpan _reconnectDelay = TimeSpan.Zero; + private Task? _worker; + + private GatewayStatusUpdate? _presence { get; init; } + + public Shard(GatewaySettings settings, ShardInfo info, IGatewayRatelimiter ratelimiter, string url, ILogger logger, GatewayStatusUpdate? presence = null) + { + _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad(); + + _settings = settings; + _info = info; + _presence = presence; + _ratelimiter = ratelimiter; + _url = url; + _logger = logger.ForContext().ForContext("ShardId", info.ShardId); + _stateManager = new ShardStateManager(info, _jsonSerializerOptions, logger) + { + HandleEvent = HandleEvent, + SendHeartbeat = SendHeartbeat, + SendIdentify = SendIdentify, + SendResume = SendResume, + Connect = ConnectInner, + Reconnect = Reconnect, + }; + _stateManager.OnHeartbeatReceived += latency => + { + HeartbeatReceived?.Invoke(latency); + }; + + _conn = new ShardConnection(_jsonSerializerOptions, _logger, info.ShardId); + } + + private async Task ShardLoop() + { + // may be superfluous but this adds shard id to ambient context which is nice + using var _ = LogContext.PushProperty("ShardId", _info.ShardId); + + while (true) + { + try + { + await ConnectInner(); + + await HandleConnectionOpened(); + + while (_conn.State == WebSocketState.Open) + { + var packet = await _conn.Read(); + if (packet == null) + break; + + await _stateManager.HandlePacketReceived(packet); + } + + await HandleConnectionClosed(_conn.CloseStatus, _conn.CloseStatusDescription); + + _logger.Information("Shard {ShardId}: Reconnecting after delay {ReconnectDelay}", + _info.ShardId, _reconnectDelay); + + if (_reconnectDelay > TimeSpan.Zero) + await Task.Delay(_reconnectDelay); + _reconnectDelay = TimeSpan.Zero; + } + catch (Exception e) + { + _logger.Error(e, "Shard {ShardId}: Error in main shard loop, reconnecting in 5 seconds...", _info.ShardId); + + // todo: exponential backoff here? this should never happen, ideally... + await Task.Delay(TimeSpan.FromSeconds(5)); + } + } + } + + public async Task Start() + { + if (_worker == null) + _worker = ShardLoop(); + + // Ideally we'd stagger the startups so we don't smash the websocket but that's difficult with the + // identify rate limiter so this is the best we can do rn, maybe? + await Task.Delay(200); + } + + public async Task UpdateStatus(GatewayStatusUpdate payload) + => await _conn.Send(new GatewayPacket + { + Opcode = GatewayOpcode.PresenceUpdate, + Payload = payload + }); + + private async Task ConnectInner() + { + while (true) + { + await _ratelimiter.Identify(_info.ShardId); + + _logger.Information("Shard {ShardId}: Connecting to WebSocket", _info.ShardId); + try + { + await _conn.Connect(_url, default); + break; + } + catch (WebSocketException e) + { + _logger.Error(e, "Shard {ShardId}: Error connecting to WebSocket, retrying in 5 seconds...", _info.ShardId); + await Task.Delay(TimeSpan.FromSeconds(5)); + } + } + } + + private Task DisconnectInner(WebSocketCloseStatus closeStatus) + => _conn.Disconnect(closeStatus, null); + + private async Task SendIdentify() + => await _conn.Send(new GatewayPacket + { + Opcode = GatewayOpcode.Identify, + Payload = new GatewayIdentify + { + Compress = false, + Intents = _settings.Intents, + Properties = new GatewayIdentify.ConnectionProperties + { + Browser = LibraryName, + Device = LibraryName, + Os = Environment.OSVersion.ToString() + }, + Shard = _info, + Token = _settings.Token, + LargeThreshold = 50, + Presence = _presence, + } + }); + + private async Task SendResume((string SessionId, int? LastSeq) arg) + => await _conn.Send(new GatewayPacket + { + Opcode = GatewayOpcode.Resume, + Payload = new GatewayResume(_settings.Token, arg.SessionId, arg.LastSeq ?? 0) + }); + + private async Task SendHeartbeat(int? lastSeq) + => await _conn.Send(new GatewayPacket { Opcode = GatewayOpcode.Heartbeat, Payload = lastSeq }); + + private async Task Reconnect(WebSocketCloseStatus closeStatus, TimeSpan delay) + { + _reconnectDelay = delay; + await DisconnectInner(closeStatus); + } + + private async Task HandleEvent(IGatewayEvent arg) + { + if (arg is ReadyEvent) + Ready?.Invoke(); + if (arg is ResumedEvent) + Resumed?.Invoke(); + + await (OnEventReceived?.Invoke(arg) ?? Task.CompletedTask); + } + + private async Task HandleConnectionOpened() + { + _logger.Information("Shard {ShardId}: Connection opened", _info.ShardId); + await _stateManager.HandleConnectionOpened(); + SocketOpened?.Invoke(); + } + + private async Task HandleConnectionClosed(WebSocketCloseStatus? closeStatus, string? description) + { + _logger.Information("Shard {ShardId}: Connection closed ({CloseStatus}/{Description})", + _info.ShardId, closeStatus, description ?? ""); + await _stateManager.HandleConnectionClosed(); + SocketClosed?.Invoke(closeStatus, description); + } +} \ No newline at end of file diff --git a/Myriad/Gateway/ShardConnection.cs b/Myriad/Gateway/ShardConnection.cs new file mode 100644 index 00000000..158762b5 --- /dev/null +++ b/Myriad/Gateway/ShardConnection.cs @@ -0,0 +1,118 @@ +using System.Net.WebSockets; +using System.Text.Json; + +using Serilog; + +namespace Myriad.Gateway; + +public class ShardConnection: IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly ShardPacketSerializer _serializer; + private ClientWebSocket? _client; + + private int _id; + + public ShardConnection(JsonSerializerOptions jsonSerializerOptions, ILogger logger, int id) + { + _logger = logger.ForContext(); + _serializer = new ShardPacketSerializer(jsonSerializerOptions); + _id = id; + } + + public WebSocketState State => _client?.State ?? WebSocketState.Closed; + public WebSocketCloseStatus? CloseStatus => _client?.CloseStatus; + public string? CloseStatusDescription => _client?.CloseStatusDescription; + + public async ValueTask DisposeAsync() + { + await CloseInner(WebSocketCloseStatus.NormalClosure, null); + _client?.Dispose(); + } + + public async Task Connect(string url, CancellationToken ct) + { + _client?.Dispose(); + _client = new ClientWebSocket(); + + await _client.ConnectAsync(GetConnectionUri(url), ct); + } + + public async Task Disconnect(WebSocketCloseStatus closeStatus, string? reason) + { + await CloseInner(closeStatus, reason); + } + + public async Task Send(GatewayPacket packet) + { + // from `ManagedWebSocket.s_validSendStates` + if (_client is not { State: WebSocketState.Open or WebSocketState.CloseReceived }) + return; + + try + { + await _serializer.WritePacket(_client, packet); + } + catch (Exception e) + { + _logger.Error(e, "Shard {ShardId}: Error sending WebSocket message"); + } + } + + public async Task Read() + { + // from `ManagedWebSocket.s_validReceiveStates` + if (_client is not { State: WebSocketState.Open or WebSocketState.CloseSent }) + return null; + + try + { + var (_, packet) = await _serializer.ReadPacket(_client); + return packet; + } + catch (Exception e) + { + _logger.Error(e, "Shard {ShardId}: Error reading from WebSocket"); + // force close so we can "reset" + await CloseInner(WebSocketCloseStatus.NormalClosure, null); + } + + return null; + } + + private Uri GetConnectionUri(string baseUri) => new UriBuilder(baseUri) { Query = "v=10&encoding=json" }.Uri; + + private async Task CloseInner(WebSocketCloseStatus closeStatus, string? description) + { + if (_client == null) + return; + + var client = _client; + _client = null; + + // from `ManagedWebSocket.s_validCloseStates` + if (client.State is WebSocketState.Open or WebSocketState.CloseReceived or WebSocketState.CloseSent) + { + // Close with timeout, mostly to work around https://github.com/dotnet/runtime/issues/51590 + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + await client.CloseAsync(closeStatus, description, cts.Token); + } + catch (Exception e) + { + _logger.Error(e, "Shard {ShardId}: Error closing WebSocket connection"); + } + } + + // This shouldn't need to be wrapped in a try/catch but doing it anyway :/ + try + { + client.Dispose(); + } + catch (Exception e) + { + _logger.Error(e, "Shard {ShardId}: Error disposing WebSocket connection"); + } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/ShardInfo.cs b/Myriad/Gateway/ShardInfo.cs new file mode 100644 index 00000000..ea1f7cb2 --- /dev/null +++ b/Myriad/Gateway/ShardInfo.cs @@ -0,0 +1,3 @@ +namespace Myriad.Gateway; + +public record ShardInfo(int ShardId, int NumShards); \ No newline at end of file diff --git a/Myriad/Gateway/ShardPacketSerializer.cs b/Myriad/Gateway/ShardPacketSerializer.cs new file mode 100644 index 00000000..dc13a88a --- /dev/null +++ b/Myriad/Gateway/ShardPacketSerializer.cs @@ -0,0 +1,68 @@ +using System.Buffers; +using System.Net.WebSockets; +using System.Text.Json; + +namespace Myriad.Gateway; + +public class ShardPacketSerializer +{ + private const int BufferSize = 64 * 1024; + + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public ShardPacketSerializer(JsonSerializerOptions jsonSerializerOptions) + { + _jsonSerializerOptions = jsonSerializerOptions; + } + + public async ValueTask<(WebSocketMessageType type, GatewayPacket? packet)> ReadPacket(ClientWebSocket socket) + { + using var buf = MemoryPool.Shared.Rent(BufferSize); + + var res = await socket.ReceiveAsync(buf.Memory, default); + if (res.MessageType == WebSocketMessageType.Close) + return (res.MessageType, null); + + if (res.EndOfMessage) + // Entire packet fits within one buffer, deserialize directly + return DeserializeSingleBuffer(buf, res); + + // Otherwise copy to stream buffer and deserialize from there + return await DeserializeMultipleBuffer(socket, buf, res); + } + + public async Task WritePacket(ClientWebSocket socket, GatewayPacket packet) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(packet, _jsonSerializerOptions); + await socket.SendAsync(bytes.AsMemory(), WebSocketMessageType.Text, true, default); + } + + private async Task<(WebSocketMessageType type, GatewayPacket packet)> DeserializeMultipleBuffer( + ClientWebSocket socket, IMemoryOwner buf, ValueWebSocketReceiveResult res) + { + await using var stream = new MemoryStream(BufferSize * 4); + stream.Write(buf.Memory.Span.Slice(0, res.Count)); + + while (!res.EndOfMessage) + { + res = await socket.ReceiveAsync(buf.Memory, default); + stream.Write(buf.Memory.Span.Slice(0, res.Count)); + } + + return DeserializeObject(res, stream.GetBuffer().AsSpan(0, (int)stream.Length)); + } + + private (WebSocketMessageType type, GatewayPacket packet) DeserializeSingleBuffer( + IMemoryOwner buf, ValueWebSocketReceiveResult res) + { + var span = buf.Memory.Span.Slice(0, res.Count); + return DeserializeObject(res, span); + } + + private (WebSocketMessageType type, GatewayPacket packet) DeserializeObject( + ValueWebSocketReceiveResult res, Span span) + { + var packet = JsonSerializer.Deserialize(span, _jsonSerializerOptions)!; + return (res.MessageType, packet); + } +} \ No newline at end of file diff --git a/Myriad/Gateway/State/HeartbeatWorker.cs b/Myriad/Gateway/State/HeartbeatWorker.cs new file mode 100644 index 00000000..f4238365 --- /dev/null +++ b/Myriad/Gateway/State/HeartbeatWorker.cs @@ -0,0 +1,58 @@ +namespace Myriad.Gateway.State; + +public class HeartbeatWorker: IAsyncDisposable +{ + private Task? _worker; + private CancellationTokenSource? _workerCts; + + public TimeSpan? CurrentHeartbeatInterval { get; private set; } + + public async ValueTask DisposeAsync() + { + await Stop(); + } + + public async ValueTask Start(TimeSpan heartbeatInterval, Func callback) + { + if (_worker != null) + await Stop(); + + CurrentHeartbeatInterval = heartbeatInterval; + _workerCts = new CancellationTokenSource(); + _worker = Worker(heartbeatInterval, callback, _workerCts.Token); + } + + public async ValueTask Stop() + { + if (_worker == null) + return; + + _workerCts?.Cancel(); + try + { + await _worker; + } + catch (TaskCanceledException) { } + + _worker?.Dispose(); + _workerCts?.Dispose(); + _worker = null; + CurrentHeartbeatInterval = null; + } + + private async Task Worker(TimeSpan heartbeatInterval, Func callback, CancellationToken ct) + { + var initialDelay = GetInitialHeartbeatDelay(heartbeatInterval); + await Task.Delay(initialDelay, ct); + + while (!ct.IsCancellationRequested) + { + await callback(); + await Task.Delay(heartbeatInterval, ct); + } + } + + private static TimeSpan GetInitialHeartbeatDelay(TimeSpan heartbeatInterval) => + // Docs specify `heartbeat_interval * random.random()` but we'll add a lil buffer :) + heartbeatInterval * (new Random().NextDouble() * 0.9 + 0.05); +} \ No newline at end of file diff --git a/Myriad/Gateway/State/ShardState.cs b/Myriad/Gateway/State/ShardState.cs new file mode 100644 index 00000000..cf89534a --- /dev/null +++ b/Myriad/Gateway/State/ShardState.cs @@ -0,0 +1,10 @@ +namespace Myriad.Gateway.State; + +public enum ShardState +{ + Disconnected, + Handshaking, + Identifying, + Connected, + Reconnecting +} \ No newline at end of file diff --git a/Myriad/Gateway/State/ShardStateManager.cs b/Myriad/Gateway/State/ShardStateManager.cs new file mode 100644 index 00000000..cba968d6 --- /dev/null +++ b/Myriad/Gateway/State/ShardStateManager.cs @@ -0,0 +1,246 @@ +using System.Net.WebSockets; +using System.Text.Json; + +using Myriad.Gateway.State; +using Myriad.Types; + +using Serilog; + +namespace Myriad.Gateway; + +public class ShardStateManager +{ + private readonly HeartbeatWorker _heartbeatWorker = new(); + + private readonly ShardInfo _info; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ILogger _logger; + private bool _hasReceivedHeartbeatAck; + + private DateTimeOffset? _lastHeartbeatSent; + private int? _lastSeq; + private TimeSpan? _latency; + + private string? _sessionId; + + public ShardStateManager(ShardInfo info, JsonSerializerOptions jsonSerializerOptions, ILogger logger) + { + _info = info; + _jsonSerializerOptions = jsonSerializerOptions; + _logger = logger.ForContext(); + } + + public ShardState State { get; private set; } = ShardState.Disconnected; + + public TimeSpan? Latency => _latency; + public User? User { get; private set; } + public ApplicationPartial? Application { get; private set; } + + public Func SendIdentify { get; init; } + public Func<(string SessionId, int? LastSeq), Task> SendResume { get; init; } + public Func SendHeartbeat { get; init; } + public Func Reconnect { get; init; } + public Func Connect { get; init; } + public Func HandleEvent { get; init; } + + public event Action OnHeartbeatReceived; + + public Task HandleConnectionOpened() + { + State = ShardState.Handshaking; + return Task.CompletedTask; + } + + public async Task HandleConnectionClosed() + { + _latency = null; + await _heartbeatWorker.Stop(); + } + + public async Task HandlePacketReceived(GatewayPacket packet) + { + switch (packet.Opcode) + { + case GatewayOpcode.Hello: + var hello = DeserializePayload(packet); + await HandleHello(hello); + break; + + case GatewayOpcode.Heartbeat: + await HandleHeartbeatRequest(); + break; + + case GatewayOpcode.HeartbeatAck: + await HandleHeartbeatAck(); + break; + + case GatewayOpcode.Reconnect: + { + await HandleReconnect(); + break; + } + + case GatewayOpcode.InvalidSession: + { + var canResume = DeserializePayload(packet); + await HandleInvalidSession(canResume); + break; + } + + case GatewayOpcode.Dispatch: + _lastSeq = packet.Sequence; + + var evt = DeserializeEvent(packet.EventType!, (JsonElement)packet.Payload!); + if (evt != null) + { + if (evt is ReadyEvent ready) + await HandleReady(ready); + + if (evt is ResumedEvent) + await HandleResumed(); + + await HandleEvent(evt); + } + + break; + } + } + + private async Task HandleHello(GatewayHello hello) + { + var interval = TimeSpan.FromMilliseconds(hello.HeartbeatInterval); + + _hasReceivedHeartbeatAck = true; + await _heartbeatWorker.Start(interval, HandleHeartbeatTimer); + await IdentifyOrResume(); + } + + private async Task IdentifyOrResume() + { + State = ShardState.Identifying; + + if (_sessionId != null) + { + _logger.Information("Shard {ShardId}: Received Hello, attempting to resume (seq {LastSeq})", + _info.ShardId, _lastSeq); + await SendResume((_sessionId!, _lastSeq)); + } + else + { + _logger.Information("Shard {ShardId}: Received Hello, identifying", + _info.ShardId); + + await SendIdentify(); + } + } + + private Task HandleHeartbeatAck() + { + _hasReceivedHeartbeatAck = true; + _latency = DateTimeOffset.UtcNow - _lastHeartbeatSent; + OnHeartbeatReceived?.Invoke(_latency!.Value); + _logger.Debug("Shard {ShardId}: Received Heartbeat (latency {Latency:N2} ms)", + _info.ShardId, _latency?.TotalMilliseconds); + return Task.CompletedTask; + } + + private async Task HandleInvalidSession(bool canResume) + { + if (!canResume) + { + _sessionId = null; + _lastSeq = null; + } + + _logger.Information("Shard {ShardId}: Received Invalid Session (can resume? {CanResume})", + _info.ShardId, canResume); + + var delay = TimeSpan.FromMilliseconds(new Random().Next(1000, 5000)); + await DoReconnect(WebSocketCloseStatus.NormalClosure, delay); + } + + private async Task HandleReconnect() + { + _logger.Information("Shard {ShardId}: Received Reconnect", _info.ShardId); + // close code 1000 kills the session, so can't reconnect + // we use 1005 (no error specified) instead + await DoReconnect(WebSocketCloseStatus.Empty, TimeSpan.FromSeconds(1)); + } + + private Task HandleReady(ReadyEvent ready) + { + _logger.Information("Shard {ShardId}: Received Ready", _info.ShardId); + + _sessionId = ready.SessionId; + State = ShardState.Connected; + User = ready.User; + Application = ready.Application; + return Task.CompletedTask; + } + + private Task HandleResumed() + { + _logger.Information("Shard {ShardId}: Received Resume", _info.ShardId); + + State = ShardState.Connected; + return Task.CompletedTask; + } + + private async Task HandleHeartbeatRequest() + { + await SendHeartbeatInternal(); + } + + private async Task SendHeartbeatInternal() + { + await SendHeartbeat(_lastSeq); + _lastHeartbeatSent = DateTimeOffset.UtcNow; + } + + private async Task HandleHeartbeatTimer() + { + if (!_hasReceivedHeartbeatAck) + { + _logger.Warning("Shard {ShardId}: Heartbeat worker timed out", _info.ShardId); + await DoReconnect(WebSocketCloseStatus.ProtocolError, TimeSpan.Zero); + return; + } + + await SendHeartbeatInternal(); + } + + private async Task DoReconnect(WebSocketCloseStatus closeStatus, TimeSpan delay) + { + State = ShardState.Reconnecting; + await Reconnect(closeStatus, delay); + } + + private T DeserializePayload(GatewayPacket packet) + { + var packetPayload = (JsonElement)packet.Payload!; + return JsonSerializer.Deserialize(packetPayload.GetRawText(), _jsonSerializerOptions)!; + } + + private IGatewayEvent? DeserializeEvent(string eventType, JsonElement payload) + { + if (!IGatewayEvent.EventTypes.TryGetValue(eventType, out var clrType)) + { + _logger.Debug("Shard {ShardId}: Received unknown event type {EventType}", _info.ShardId, eventType); + return null; + } + + try + { + _logger.Verbose("Shard {ShardId}: Deserializing {EventType} to {ClrType}", _info.ShardId, eventType, + clrType); + return JsonSerializer.Deserialize(payload.GetRawText(), clrType, _jsonSerializerOptions) + as IGatewayEvent; + } + catch (JsonException e) + { + _logger.Error(e, "Shard {ShardId}: Error deserializing event {EventType} to {ClrType}", _info.ShardId, + eventType, clrType); + return null; + } + } +} \ No newline at end of file diff --git a/Myriad/Myriad.csproj b/Myriad/Myriad.csproj new file mode 100644 index 00000000..62bbe267 --- /dev/null +++ b/Myriad/Myriad.csproj @@ -0,0 +1,37 @@ + + + + net6.0 + enable + enable + + + + + $(NoWarn);8618 + + + + true + full + + + + true + + + + + + + + + + + + + + + + + diff --git a/Myriad/README.md b/Myriad/README.md new file mode 100644 index 00000000..9ddc72cf --- /dev/null +++ b/Myriad/README.md @@ -0,0 +1,5 @@ +## Myriad + +'Myriad' is a .NET library used to interact with the Discord API. It's primarily intended for use with PluralKit, but feel free to fork or submodule it! + +You can find a simple example bot using the Myriad library here: \ No newline at end of file diff --git a/Myriad/Rest/BaseRestClient.cs b/Myriad/Rest/BaseRestClient.cs new file mode 100644 index 00000000..8877027b --- /dev/null +++ b/Myriad/Rest/BaseRestClient.cs @@ -0,0 +1,335 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; + +using Myriad.Rest.Exceptions; +using Myriad.Rest.Ratelimit; +using Myriad.Rest.Types; +using Myriad.Serialization; + +using Polly; + +using Serilog; +using Serilog.Context; + +namespace Myriad.Rest; + +public class BaseRestClient: IAsyncDisposable +{ + private readonly string _baseUrl; + private readonly Version _httpVersion = new(1, 1); + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ILogger _logger; + private readonly Ratelimiter _ratelimiter; + private readonly AsyncPolicy _retryPolicy; + public EventHandler<(string, int, long)> OnResponseEvent; + + public BaseRestClient(string userAgent, string token, ILogger logger, string baseUrl) + { + _logger = logger.ForContext(); + _baseUrl = baseUrl; + + if (!token.StartsWith("Bot ")) + token = "Bot " + token; + + Client = new HttpClient(); + Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent); + Client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", token); + + _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad(); + + _ratelimiter = new Ratelimiter(logger); + var discordPolicy = new DiscordRateLimitPolicy(_ratelimiter); + + // todo: why doesn't the timeout work? o.o + var timeoutPolicy = Policy.TimeoutAsync(TimeSpan.FromSeconds(10)); + + var waitPolicy = Policy + .Handle() + .WaitAndRetryAsync(3, + (_, e, _) => ((RatelimitBucketExhaustedException)e).RetryAfter, + (_, _, _, _) => Task.CompletedTask) + .AsAsyncPolicy(); + + _retryPolicy = Policy.WrapAsync(timeoutPolicy, waitPolicy, discordPolicy); + } + + public HttpClient Client { get; } + + public ValueTask DisposeAsync() + { + _ratelimiter.Dispose(); + Client.Dispose(); + return default; + } + + public async Task Get(string path, (string endpointName, ulong major) ratelimitParams) where T : class + { + using var response = await Send(() => new HttpRequestMessage(HttpMethod.Get, _baseUrl + path), + ratelimitParams, true); + + // GET-only special case: 404s are nulls and not exceptions + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + return await ReadResponse(response); + } + + public async Task Post(string path, (string endpointName, ulong major) ratelimitParams, object? body) + where T : class + { + using var response = await Send(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path); + SetRequestJsonBody(request, body); + return request; + }, ratelimitParams); + return await ReadResponse(response); + } + + public async Task PostMultipart(string path, (string endpointName, ulong major) ratelimitParams, + object? payload, MultipartFile[]? files) + where T : class + { + using var response = await Send(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path); + SetRequestFormDataBody(request, payload, files); + return request; + }, ratelimitParams); + return await ReadResponse(response); + } + + public async Task Patch(string path, (string endpointName, ulong major) ratelimitParams, object? body) + where T : class + { + using var response = await Send(() => + { + var request = new HttpRequestMessage(HttpMethod.Patch, _baseUrl + path); + SetRequestJsonBody(request, body); + return request; + }, ratelimitParams); + return await ReadResponse(response); + } + + public async Task Put(string path, (string endpointName, ulong major) ratelimitParams, object? body) + where T : class + { + using var response = await Send(() => + { + var request = new HttpRequestMessage(HttpMethod.Put, _baseUrl + path); + SetRequestJsonBody(request, body); + return request; + }, ratelimitParams); + return await ReadResponse(response); + } + + public async Task Delete(string path, (string endpointName, ulong major) ratelimitParams) + { + using var _ = await Send(() => new HttpRequestMessage(HttpMethod.Delete, _baseUrl + path), ratelimitParams); + } + + private void SetRequestJsonBody(HttpRequestMessage request, object? body) + { + if (body == null) return; + request.Content = + new ReadOnlyMemoryContent(JsonSerializer.SerializeToUtf8Bytes(body, _jsonSerializerOptions)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + + private void SetRequestFormDataBody(HttpRequestMessage request, object? payload, MultipartFile[]? files) + { + var bodyJson = JsonSerializer.SerializeToUtf8Bytes(payload, _jsonSerializerOptions); + + var mfd = new MultipartFormDataContent(); + mfd.Add(new ByteArrayContent(bodyJson), "payload_json"); + + if (files != null) + for (var i = 0; i < files.Length; i++) + { + var (filename, stream, _) = files[i]; + mfd.Add(new StreamContent(stream), $"files[{i}]", filename); + } + + request.Content = mfd; + } + + private async Task ReadResponse(HttpResponseMessage response) where T : class + { + if (response.StatusCode == HttpStatusCode.NoContent) + return null; + return await response.Content.ReadFromJsonAsync(_jsonSerializerOptions); + } + + private async Task Send(Func createRequest, + (string endpointName, ulong major) ratelimitParams, + bool ignoreNotFound = false) + { + return await _retryPolicy.ExecuteAsync(async _ => + { + using var __ = LogContext.PushProperty("EndpointName", ratelimitParams.endpointName); + + var request = createRequest(); + _logger.Debug("Request: {RequestMethod} {RequestPath}", + request.Method, CleanForLogging(request.RequestUri!)); + + request.Version = _httpVersion; + request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + + HttpResponseMessage response; + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + try + { + response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + stopwatch.Stop(); + } + catch (Exception exc) + { + _logger.Error(exc, "HTTP error: {RequestMethod} {RequestUrl}", request.Method, + CleanForLogging(request.RequestUri!)); + + // kill the running thread + // in PluralKit.Bot, this error is ignored in "IsOurProblem" (PluralKit.Bot/Utils/MiscUtils.cs) + throw; + } + + _logger.Debug( + "Response: {RequestMethod} {RequestPath} -> {StatusCode} {ReasonPhrase} (in {ResponseDurationMs} ms)", + request.Method, CleanForLogging(request.RequestUri!), (int)response.StatusCode, + response.ReasonPhrase, stopwatch.ElapsedMilliseconds); + + await HandleApiError(response, ignoreNotFound); + + OnResponseEvent?.Invoke(null, ( + GetEndpointMetricsName(response.RequestMessage!), + (int)response.StatusCode, + stopwatch.ElapsedTicks + )); + + return response; + }, + new Dictionary + { + {DiscordRateLimitPolicy.EndpointContextKey, ratelimitParams.endpointName}, + {DiscordRateLimitPolicy.MajorContextKey, ratelimitParams.major} + }); + } + + private async ValueTask HandleApiError(HttpResponseMessage response, bool ignoreNotFound) + { + if (response.IsSuccessStatusCode) + return; + + if (response.StatusCode == HttpStatusCode.NotFound && ignoreNotFound) + return; + + var body = await response.Content.ReadAsStringAsync(); + var apiError = TryParseApiError(body); + if (apiError != null) + { + string? xRateLimitScope = ""; + + try + { + var ratelimitHeader = response.Headers.FirstOrDefault(x => x.Key == "x-ratelimit-scope"); + xRateLimitScope = ratelimitHeader.Value.FirstOrDefault(); + if (xRateLimitScope == "global") + _logger.Error("We are globally ratelimited!"); + } + catch (Exception) { } + + using var __ = LogContext.PushProperty("RatelimitScope", xRateLimitScope); + using var _ = LogContext.PushProperty("DiscordErrorBody", body); + _logger.Warning("Discord API error: {DiscordErrorCode} {DiscordErrorMessage}", apiError.Code, + apiError.Message); + } + + throw CreateDiscordException(response, body, apiError); + } + + private DiscordRequestException CreateDiscordException(HttpResponseMessage response, string body, + DiscordApiError? apiError) + { + return response.StatusCode switch + { + HttpStatusCode.BadRequest => new BadRequestException(response, body, apiError), + HttpStatusCode.Forbidden => new ForbiddenException(response, body, apiError), + HttpStatusCode.Unauthorized => new UnauthorizedException(response, body, apiError), + HttpStatusCode.NotFound => new NotFoundException(response, body, apiError), + HttpStatusCode.Conflict => new ConflictException(response, body, apiError), + HttpStatusCode.TooManyRequests => new TooManyRequestsException(response, body, apiError), + _ => new UnknownDiscordRequestException(response, body, apiError) + }; + } + + private DiscordApiError? TryParseApiError(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + return null; + + try + { + return JsonSerializer.Deserialize(responseBody, _jsonSerializerOptions); + } + catch (JsonException e) + { + _logger.Verbose(e, "Error deserializing API error"); + } + + return null; + } + + private string NormalizeRoutePath(string url) + { + url = Regex.Replace(url, @"/channels/\d+", "/channels/{channel_id}"); + url = Regex.Replace(url, @"/messages/\d+", "/messages/{message_id}"); + url = Regex.Replace(url, @"/members/\d+", "/members/{user_id}"); + url = Regex.Replace(url, @"/webhooks/\d+/[^/]+", "/webhooks/{webhook_id}/{webhook_token}"); + url = Regex.Replace(url, @"/webhooks/\d+", "/webhooks/{webhook_id}"); + url = Regex.Replace(url, @"/users/\d+", "/users/{user_id}"); + url = Regex.Replace(url, @"/bans/\d+", "/bans/{user_id}"); + url = Regex.Replace(url, @"/roles/\d+", "/roles/{role_id}"); + url = Regex.Replace(url, @"/pins/\d+", "/pins/{message_id}"); + url = Regex.Replace(url, @"/emojis/\d+", "/emojis/{emoji_id}"); + url = Regex.Replace(url, @"/guilds/\d+", "/guilds/{guild_id}"); + url = Regex.Replace(url, @"/integrations/\d+", "/integrations/{integration_id}"); + url = Regex.Replace(url, @"/permissions/\d+", "/permissions/{overwrite_id}"); + url = Regex.Replace(url, @"/reactions/[^{/]+/\d+", "/reactions/{emoji}/{user_id}"); + url = Regex.Replace(url, @"/reactions/[^{/]+", "/reactions/{emoji}"); + url = Regex.Replace(url, @"/invites/[^{/]+", "/invites/{invite_code}"); + url = Regex.Replace(url, @"/interactions/\d+/[^{/]+", "/interactions/{interaction_id}/{interaction_token}"); + url = Regex.Replace(url, @"/interactions/\d+", "/interactions/{interaction_id}"); + + // catch-all for missed IDs + url = Regex.Replace(url, @"\d{17,19}", "{snowflake}"); + + return url; + } + + private string GetEndpointMetricsName(HttpRequestMessage req) + { + var localPath = Regex.Replace(req.RequestUri!.LocalPath, @"/api/v\d+", ""); + var routePath = NormalizeRoutePath(localPath); + return $"{req.Method} {routePath}"; + } + + private string CleanForLogging(Uri uri) + { + var path = uri.ToString(); + + // don't show tokens in logs + // todo: anything missing here? + path = Regex.Replace(path, @"/webhooks/(\d+)/[^/]+", "/webhooks/$1/:token"); + path = Regex.Replace(path, @"/interactions/(\d+)/[^{/]+", "/interactions/$1/:token"); + + // remove base URL + path = path.Substring(_baseUrl.Length); + + return path; + } +} \ No newline at end of file diff --git a/Myriad/Rest/DiscordApiClient.cs b/Myriad/Rest/DiscordApiClient.cs new file mode 100644 index 00000000..7fd89356 --- /dev/null +++ b/Myriad/Rest/DiscordApiClient.cs @@ -0,0 +1,151 @@ +using System.Net; + +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; + +using Serilog; + +namespace Myriad.Rest; + +public class DiscordApiClient +{ + public const string UserAgent = "DiscordBot (https://github.com/xSke/PluralKit/tree/main/Myriad/, v1)"; + private const string DefaultApiBaseUrl = "https://discord.com/api/v10"; + private readonly BaseRestClient _client; + + public EventHandler<(string, int, long)> OnResponseEvent; + + public DiscordApiClient(string token, ILogger logger, string? baseUrl = null) + { + _client = new BaseRestClient(UserAgent, token, logger, baseUrl ?? DefaultApiBaseUrl); + _client.OnResponseEvent += (_, ev) => OnResponseEvent?.Invoke(null, ev); + } + + public Task GetGateway() => + _client.Get("/gateway", ("GetGateway", default))!; + + public Task GetGatewayBot() => + _client.Get("/gateway/bot", ("GetGatewayBot", default))!; + + public Task GetChannel(ulong channelId) => + _client.Get($"/channels/{channelId}", ("GetChannel", channelId)); + + public Task GetMessage(ulong channelId, ulong messageId) => + _client.Get($"/channels/{channelId}/messages/{messageId}", ("GetMessage", channelId)); + + public Task GetGuild(ulong id) => + _client.Get($"/guilds/{id}", ("GetGuild", id)); + + public Task GetGuildChannels(ulong id) => + _client.Get($"/guilds/{id}/channels", ("GetGuildChannels", id))!; + + public Task GetUser(ulong id) => + _client.Get($"/users/{id}", ("GetUser", default)); + + public Task GetGuildMember(ulong guildId, ulong userId) => + _client.Get($"/guilds/{guildId}/members/{userId}", + ("GetGuildMember", guildId)); + + public Task CreateMessage(ulong channelId, MessageRequest request, MultipartFile[]? files = null) => + _client.PostMultipart($"/channels/{channelId}/messages", ("CreateMessage", channelId), request, + files)!; + + public Task EditMessage(ulong channelId, ulong messageId, MessageEditRequest request) => + _client.Patch($"/channels/{channelId}/messages/{messageId}", ("EditMessage", channelId), request)!; + + public Task DeleteMessage(ulong channelId, ulong messageId) => + _client.Delete($"/channels/{channelId}/messages/{messageId}", ("DeleteMessage", channelId)); + + public Task DeleteMessage(Message message) => + _client.Delete($"/channels/{message.ChannelId}/messages/{message.Id}", + ("DeleteMessage", message.ChannelId)); + + public Task CreateReaction(ulong channelId, ulong messageId, Emoji emoji) => + _client.Put($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/@me", + ("CreateReaction", channelId), null); + + public Task DeleteOwnReaction(ulong channelId, ulong messageId, Emoji emoji) => + _client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/@me", + ("DeleteOwnReaction", channelId)); + + public Task DeleteUserReaction(ulong channelId, ulong messageId, Emoji emoji, ulong userId) => + _client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/{userId}", + ("DeleteUserReaction", channelId)); + + public Task DeleteAllReactions(ulong channelId, ulong messageId) => + _client.Delete($"/channels/{channelId}/messages/{messageId}/reactions", + ("DeleteAllReactions", channelId)); + + public Task DeleteAllReactionsForEmoji(ulong channelId, ulong messageId, Emoji emoji) => + _client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}", + ("DeleteAllReactionsForEmoji", channelId)); + + public Task CreateGlobalApplicationCommand(ulong applicationId, + ApplicationCommandRequest request) => + _client.Post($"/applications/{applicationId}/commands", + ("CreateGlobalApplicationCommand", applicationId), request)!; + + public Task GetGuildApplicationCommands(ulong applicationId, ulong guildId) => + _client.Get($"/applications/{applicationId}/guilds/{guildId}/commands", + ("GetGuildApplicationCommands", applicationId))!; + + public Task CreateGuildApplicationCommand(ulong applicationId, ulong guildId, + ApplicationCommandRequest request) => + _client.Post($"/applications/{applicationId}/guilds/{guildId}/commands", + ("CreateGuildApplicationCommand", applicationId), request)!; + + public Task EditGuildApplicationCommand(ulong applicationId, ulong guildId, + ApplicationCommandRequest request) => + _client.Patch($"/applications/{applicationId}/guilds/{guildId}/commands", + ("EditGuildApplicationCommand", applicationId), request)!; + + public Task DeleteGuildApplicationCommand(ulong applicationId, ulong commandId) => + _client.Delete($"/applications/{applicationId}/commands/{commandId}", + ("DeleteGuildApplicationCommand", applicationId)); + + public Task CreateInteractionResponse(ulong interactionId, string token, InteractionResponse response) => + _client.Post($"/interactions/{interactionId}/{token}/callback", + ("CreateInteractionResponse", interactionId), response); + + public Task ModifyGuildMember(ulong guildId, ulong userId, ModifyGuildMemberRequest request) => + _client.Patch($"/guilds/{guildId}/members/{userId}", + ("ModifyGuildMember", guildId), request); + + public Task CreateWebhook(ulong channelId, CreateWebhookRequest request) => + _client.Post($"/channels/{channelId}/webhooks", ("CreateWebhook", channelId), request)!; + + public Task GetWebhook(ulong webhookId) => + _client.Get($"/webhooks/{webhookId}/webhooks", ("GetWebhook", webhookId))!; + + public Task GetChannelWebhooks(ulong channelId) => + _client.Get($"/channels/{channelId}/webhooks", ("GetChannelWebhooks", channelId))!; + + public Task ExecuteWebhook(ulong webhookId, string webhookToken, ExecuteWebhookRequest request, + MultipartFile[]? files = null, ulong? threadId = null) + { + var url = $"/webhooks/{webhookId}/{webhookToken}?wait=true"; + if (threadId != null) + url += $"&thread_id={threadId}"; + + return _client.PostMultipart(url, + ("ExecuteWebhook", webhookId), request, files)!; + } + + public Task EditWebhookMessage(ulong webhookId, string webhookToken, ulong messageId, + WebhookMessageEditRequest request, ulong? threadId = null) + { + var url = $"/webhooks/{webhookId}/{webhookToken}/messages/{messageId}"; + if (threadId != null) + url += $"?thread_id={threadId}"; + + return _client.Patch(url, ("EditWebhookMessage", webhookId), request)!; + } + + public Task CreateDm(ulong recipientId) => + _client.Post("/users/@me/channels", ("CreateDM", default), new CreateDmRequest(recipientId))!; + + private static string EncodeEmoji(Emoji emoji) => + WebUtility.UrlEncode(emoji.Id != null ? $"{emoji.Name}:{emoji.Id}" : emoji.Name) ?? + throw new ArgumentException("Could not encode emoji"); +} \ No newline at end of file diff --git a/Myriad/Rest/DiscordApiError.cs b/Myriad/Rest/DiscordApiError.cs new file mode 100644 index 00000000..31e3d80d --- /dev/null +++ b/Myriad/Rest/DiscordApiError.cs @@ -0,0 +1,8 @@ +using System.Text.Json; + +namespace Myriad.Rest; + +public record DiscordApiError(string Message, int Code) +{ + public JsonElement? Errors { get; init; } +} \ No newline at end of file diff --git a/Myriad/Rest/Exceptions/DiscordRequestException.cs b/Myriad/Rest/Exceptions/DiscordRequestException.cs new file mode 100644 index 00000000..69486cfa --- /dev/null +++ b/Myriad/Rest/Exceptions/DiscordRequestException.cs @@ -0,0 +1,75 @@ +using System.Net; + +namespace Myriad.Rest.Exceptions; + +public class DiscordRequestException: Exception +{ + public DiscordRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) + { + ResponseBody = responseBody; + Response = response; + ApiError = apiError; + } + + public string ResponseBody { get; init; } = null!; + public HttpResponseMessage Response { get; init; } = null!; + + public HttpStatusCode StatusCode => Response.StatusCode; + public int? ErrorCode => ApiError?.Code; + + internal DiscordApiError? ApiError { get; init; } + + public override string Message => + (ApiError?.Message ?? Response.ReasonPhrase ?? "") + (FormError != null ? $": {FormError}" : ""); + + public string? FormError => ApiError?.Errors?.ToString(); +} + +public class NotFoundException: DiscordRequestException +{ + public NotFoundException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base( + response, responseBody, apiError) + { } +} + +public class UnauthorizedException: DiscordRequestException +{ + public UnauthorizedException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : + base( + response, responseBody, apiError) + { } +} + +public class ForbiddenException: DiscordRequestException +{ + public ForbiddenException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base( + response, responseBody, apiError) + { } +} + +public class ConflictException: DiscordRequestException +{ + public ConflictException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base( + response, responseBody, apiError) + { } +} + +public class BadRequestException: DiscordRequestException +{ + public BadRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base( + response, responseBody, apiError) + { } +} + +public class TooManyRequestsException: DiscordRequestException +{ + public TooManyRequestsException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : + base(response, responseBody, apiError) + { } +} + +public class UnknownDiscordRequestException: DiscordRequestException +{ + public UnknownDiscordRequestException(HttpResponseMessage response, string responseBody, + DiscordApiError? apiError) : base(response, responseBody, apiError) { } +} \ No newline at end of file diff --git a/Myriad/Rest/Exceptions/RatelimitException.cs b/Myriad/Rest/Exceptions/RatelimitException.cs new file mode 100644 index 00000000..14125fc3 --- /dev/null +++ b/Myriad/Rest/Exceptions/RatelimitException.cs @@ -0,0 +1,26 @@ +using Myriad.Rest.Ratelimit; + +namespace Myriad.Rest.Exceptions; + +public class RatelimitException: Exception +{ + public RatelimitException(string? message) : base(message) { } +} + +public class RatelimitBucketExhaustedException: RatelimitException +{ + public RatelimitBucketExhaustedException(Bucket bucket, TimeSpan retryAfter) : base( + "Rate limit bucket exhausted, request blocked") + { + Bucket = bucket; + RetryAfter = retryAfter; + } + + public Bucket Bucket { get; } + public TimeSpan RetryAfter { get; } +} + +public class GloballyRatelimitedException: RatelimitException +{ + public GloballyRatelimitedException() : base("Global rate limit hit") { } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/Bucket.cs b/Myriad/Rest/Ratelimit/Bucket.cs new file mode 100644 index 00000000..6c19da41 --- /dev/null +++ b/Myriad/Rest/Ratelimit/Bucket.cs @@ -0,0 +1,172 @@ +using Serilog; + +namespace Myriad.Rest.Ratelimit; + +public class Bucket +{ + private static readonly TimeSpan Epsilon = TimeSpan.FromMilliseconds(10); + private static readonly TimeSpan FallbackDelay = TimeSpan.FromMilliseconds(200); + + private static readonly TimeSpan StaleTimeout = TimeSpan.FromSeconds(5); + + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private bool _hasReceivedHeaders; + + private DateTimeOffset? _nextReset; + private bool _resetTimeValid; + + public Bucket(ILogger logger, string key, ulong major, int limit) + { + _logger = logger.ForContext(); + + Key = key; + Major = major; + + Limit = limit; + Remaining = limit; + _resetTimeValid = false; + } + + public string Key { get; } + public ulong Major { get; } + + public int Remaining { get; private set; } + + public int Limit { get; private set; } + + public DateTimeOffset LastUsed { get; private set; } = DateTimeOffset.UtcNow; + + public bool TryAcquire() + { + LastUsed = DateTimeOffset.Now; + + try + { + _semaphore.Wait(); + + if (Remaining > 0) + { + _logger.Verbose( + "{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit} left], allowing through", + Key, Major, Remaining, Limit); + Remaining--; + + return true; + } + + _logger.Debug("{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit}] left, denying", + Key, Major, Remaining, Limit); + return false; + } + finally + { + _semaphore.Release(); + } + } + + public void HandleResponse(RatelimitHeaders headers) + { + try + { + _semaphore.Wait(); + + _logger.Verbose("{BucketKey}/{BucketMajor}: Received rate limit headers: {@RateLimitHeaders}", + Key, Major, headers); + + if (headers.ResetAfter != null) + { + var headerNextReset = DateTimeOffset.UtcNow + headers.ResetAfter.Value; // todo: server time + if (_nextReset == null || headerNextReset > _nextReset) + { + _logger.Verbose( + "{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server (after: {NextResetAfter}, remaining: {Remaining}, local remaining: {LocalRemaining})", + Key, Major, headerNextReset, headers.ResetAfter.Value, headers.Remaining, Remaining + ); + + _nextReset = headerNextReset; + _resetTimeValid = true; + } + } + + if (headers.Limit != null) + Limit = headers.Limit.Value; + + if (headers.Remaining != null && !_hasReceivedHeaders) + { + var oldRemaining = Remaining; + Remaining = Math.Min(headers.Remaining.Value, Remaining); + + _logger.Debug( + "{BucketKey}/{BucketMajor}: Received first remaining of {HeaderRemaining}, previous local remaining is {LocalRemaining}, new local remaining is {Remaining}", + Key, Major, headers.Remaining.Value, oldRemaining, Remaining); + _hasReceivedHeaders = true; + } + } + finally + { + _semaphore.Release(); + } + } + + public void Tick(DateTimeOffset now) + { + try + { + _semaphore.Wait(); + + // If we don't have any reset data, "snap" it to now + // This happens before first request and at this point the reset is invalid anyway, so it's fine + // but it ensures the stale timeout doesn't trigger early by using `default` value + if (_nextReset == null) + _nextReset = now; + + // If we're past the reset time *and* we haven't reset already, do that + var timeSinceReset = now - _nextReset; + var shouldReset = _resetTimeValid && timeSinceReset > TimeSpan.Zero; + if (shouldReset) + { + _logger.Debug("{BucketKey}/{BucketMajor}: Bucket timed out, refreshing with {BucketLimit} requests", + Key, Major, Limit); + Remaining = Limit; + _resetTimeValid = false; + return; + } + + // We've run out of requests without having any new reset time, + // *and* it's been longer than a set amount - add one request back to the pool and hope that one returns + var isBucketStale = !_resetTimeValid && Remaining <= 0 && timeSinceReset > StaleTimeout; + if (isBucketStale) + { + _logger.Warning( + "{BucketKey}/{BucketMajor}: Bucket is stale ({StaleTimeout} passed with no rate limit info), allowing one request through", + Key, Major, StaleTimeout); + + Remaining = 1; + + // Reset the (still-invalid) reset time to now, so we don't keep hitting this conditional over and over... + _nextReset = now; + } + } + finally + { + _semaphore.Release(); + } + } + + public TimeSpan GetResetDelay(DateTimeOffset now) + { + // If we don't have a valid reset time, return the fallback delay always + // (so it'll keep spinning until we hopefully have one...) + if (!_resetTimeValid) + return FallbackDelay; + + var delay = (_nextReset ?? now) - now; + + // If we have a really small (or negative) value, return a fallback delay too + if (delay < Epsilon) + return FallbackDelay; + + return delay; + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/BucketManager.cs b/Myriad/Rest/Ratelimit/BucketManager.cs new file mode 100644 index 00000000..a89d5f77 --- /dev/null +++ b/Myriad/Rest/Ratelimit/BucketManager.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; + +using Serilog; + +namespace Myriad.Rest.Ratelimit; + +public class BucketManager: IDisposable +{ + private static readonly TimeSpan StaleBucketTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan PruneWorkerInterval = TimeSpan.FromMinutes(1); + private readonly ConcurrentDictionary<(string key, ulong major), Bucket> _buckets = new(); + + private readonly ConcurrentDictionary _endpointKeyMap = new(); + private readonly ConcurrentDictionary _knownKeyLimits = new(); + + private readonly ILogger _logger; + + private readonly Task _worker; + private readonly CancellationTokenSource _workerCts = new(); + + public BucketManager(ILogger logger) + { + _logger = logger.ForContext(); + _worker = PruneWorker(_workerCts.Token); + } + + public void Dispose() + { + _workerCts.Dispose(); + _worker.Dispose(); + } + + public Bucket? GetBucket(string endpoint, ulong major) + { + if (!_endpointKeyMap.TryGetValue(endpoint, out var key)) + return null; + + if (_buckets.TryGetValue((key, major), out var bucket)) + return bucket; + + if (!_knownKeyLimits.TryGetValue(key, out var knownLimit)) + return null; + + _logger.Debug("Creating new bucket {BucketKey}/{BucketMajor} with limit {KnownLimit}", key, major, + knownLimit); + return _buckets.GetOrAdd((key, major), + k => new Bucket(_logger, k.Item1, k.Item2, knownLimit)); + } + + public void UpdateEndpointInfo(string endpoint, string key, int? limit) + { + _endpointKeyMap[endpoint] = key; + + if (limit != null) + _knownKeyLimits[key] = limit.Value; + } + + private async Task PruneWorker(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(PruneWorkerInterval, ct); + PruneStaleBuckets(DateTimeOffset.UtcNow); + } + } + + private void PruneStaleBuckets(DateTimeOffset now) + { + foreach (var (key, bucket) in _buckets) + { + if (now - bucket.LastUsed <= StaleBucketTimeout) + continue; + + _logger.Debug("Pruning unused bucket {BucketKey}/{BucketMajor} (last used at {BucketLastUsed})", + bucket.Key, bucket.Major, bucket.LastUsed); + _buckets.TryRemove(key, out _); + } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs b/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs new file mode 100644 index 00000000..82c4163c --- /dev/null +++ b/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs @@ -0,0 +1,40 @@ +using Polly; + +namespace Myriad.Rest.Ratelimit; + +public class DiscordRateLimitPolicy: AsyncPolicy +{ + public const string EndpointContextKey = "Endpoint"; + public const string MajorContextKey = "Major"; + + private readonly Ratelimiter _ratelimiter; + + public DiscordRateLimitPolicy(Ratelimiter ratelimiter, PolicyBuilder? policyBuilder = null) + : base(policyBuilder) + { + _ratelimiter = ratelimiter; + } + + protected override async Task ImplementationAsync( + Func> action, Context context, CancellationToken ct, + bool continueOnCapturedContext) + { + if (!context.TryGetValue(EndpointContextKey, out var endpointObj) || !(endpointObj is string endpoint)) + throw new ArgumentException("Must provide endpoint in Polly context"); + + if (!context.TryGetValue(MajorContextKey, out var majorObj) || !(majorObj is ulong major)) + throw new ArgumentException("Must provide major in Polly context"); + + // Check rate limit, throw if we're not allowed... + _ratelimiter.AllowRequestOrThrow(endpoint, major, DateTimeOffset.Now); + + // We're OK, push it through + var response = await action(context, ct).ConfigureAwait(continueOnCapturedContext); + + // Update rate limit state with headers + var headers = RatelimitHeaders.Parse(response); + _ratelimiter.HandleResponse(headers, endpoint, major); + + return response; + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/RatelimitHeaders.cs b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs new file mode 100644 index 00000000..423b3beb --- /dev/null +++ b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs @@ -0,0 +1,79 @@ +using System.Globalization; + +namespace Myriad.Rest.Ratelimit; + +public record RatelimitHeaders +{ + private const string LimitHeader = "X-RateLimit-Limit"; + private const string RemainingHeader = "X-RateLimit-Remaining"; + private const string ResetHeader = "X-RateLimit-Reset"; + private const string ResetAfterHeader = "X-RateLimit-Reset-After"; + private const string BucketHeader = "X-RateLimit-Bucket"; + private const string GlobalHeader = "X-RateLimit-Global"; + + public bool Global { get; private set; } + public int? Limit { get; private set; } + public int? Remaining { get; private set; } + public DateTimeOffset? Reset { get; private set; } + public TimeSpan? ResetAfter { get; private set; } + public string? Bucket { get; private set; } + + public DateTimeOffset? ServerDate { get; private set; } + + public bool HasRatelimitInfo => + Limit != null && Remaining != null && Reset != null && ResetAfter != null && Bucket != null; + + public static RatelimitHeaders Parse(HttpResponseMessage response) + { + var headers = new RatelimitHeaders + { + ServerDate = response.Headers.Date, + Limit = TryGetInt(response, LimitHeader), + Remaining = TryGetInt(response, RemainingHeader), + Bucket = TryGetHeader(response, BucketHeader) + }; + + + var resetTimestamp = TryGetDouble(response, ResetHeader); + if (resetTimestamp != null) + headers.Reset = DateTimeOffset.FromUnixTimeMilliseconds((long)(resetTimestamp.Value * 1000)); + + var resetAfterSeconds = TryGetDouble(response, ResetAfterHeader); + if (resetAfterSeconds != null) + headers.ResetAfter = TimeSpan.FromSeconds(resetAfterSeconds.Value); + + var global = TryGetHeader(response, GlobalHeader); + if (global != null && bool.TryParse(global, out var globalBool)) + headers.Global = globalBool; + + return headers; + } + + private static string? TryGetHeader(HttpResponseMessage response, string headerName) + { + if (!response.Headers.TryGetValues(headerName, out var values)) + return null; + + return values.FirstOrDefault(); + } + + private static int? TryGetInt(HttpResponseMessage response, string headerName) + { + var valueString = TryGetHeader(response, headerName); + + if (!int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + return null; + + return value; + } + + private static double? TryGetDouble(HttpResponseMessage response, string headerName) + { + var valueString = TryGetHeader(response, headerName); + + if (!double.TryParse(valueString, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + return null; + + return value; + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/Ratelimiter.cs b/Myriad/Rest/Ratelimit/Ratelimiter.cs new file mode 100644 index 00000000..b06e5bcf --- /dev/null +++ b/Myriad/Rest/Ratelimit/Ratelimiter.cs @@ -0,0 +1,83 @@ +using Myriad.Rest.Exceptions; + +using Serilog; + +namespace Myriad.Rest.Ratelimit; + +public class Ratelimiter: IDisposable +{ + private readonly BucketManager _buckets; + private readonly ILogger _logger; + + private DateTimeOffset? _globalRateLimitExpiry; + + public Ratelimiter(ILogger logger) + { + _logger = logger.ForContext(); + _buckets = new BucketManager(logger); + } + + public void Dispose() + { + _buckets.Dispose(); + } + + public void AllowRequestOrThrow(string endpoint, ulong major, DateTimeOffset now) + { + if (IsGloballyRateLimited(now)) + { + _logger.Warning("Globally rate limited until {GlobalRateLimitExpiry}, cancelling request", + _globalRateLimitExpiry); + throw new GloballyRatelimitedException(); + } + + var bucket = _buckets.GetBucket(endpoint, major); + if (bucket == null) + { + // No rate limit for this endpoint (yet), allow through + _logger.Debug("No rate limit data for endpoint {Endpoint}, allowing through", endpoint); + return; + } + + bucket.Tick(now); + + if (bucket.TryAcquire()) + // We're allowed to send it! :) + return; + + // We can't send this request right now; retrying... + var waitTime = bucket.GetResetDelay(now); + + // add a small buffer for Timing:tm: + waitTime += TimeSpan.FromMilliseconds(50); + + // (this is caught by a WaitAndRetry Polly handler, if configured) + throw new RatelimitBucketExhaustedException(bucket, waitTime); + } + + public void HandleResponse(RatelimitHeaders headers, string endpoint, ulong major) + { + if (!headers.HasRatelimitInfo) + return; + + // TODO: properly calculate server time? + if (headers.Global) + { + _logger.Warning( + "Global rate limit hit, resetting at {GlobalRateLimitExpiry} (in {GlobalRateLimitResetAfter}!", + _globalRateLimitExpiry, headers.ResetAfter); + _globalRateLimitExpiry = headers.Reset; + } + else + { + // Update buckets first, then get it again, to properly "transfer" this info over to the new value + _buckets.UpdateEndpointInfo(endpoint, headers.Bucket!, headers.Limit); + + var bucket = _buckets.GetBucket(endpoint, major); + bucket?.HandleResponse(headers); + } + } + + private bool IsGloballyRateLimited(DateTimeOffset now) => + _globalRateLimitExpiry > now; +} \ No newline at end of file diff --git a/Myriad/Rest/Types/AllowedMentions.cs b/Myriad/Rest/Types/AllowedMentions.cs new file mode 100644 index 00000000..410de717 --- /dev/null +++ b/Myriad/Rest/Types/AllowedMentions.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +using Myriad.Serialization; + +namespace Myriad.Rest.Types; + +public record AllowedMentions +{ + [JsonConverter(typeof(JsonSnakeCaseStringEnumConverter))] + public enum ParseType + { + Roles, + Users, + Everyone + } + + public ParseType[]? Parse { get; set; } + public ulong[]? Users { get; set; } + public ulong[]? Roles { get; set; } + public bool RepliedUser { get; set; } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/MultipartFile.cs b/Myriad/Rest/Types/MultipartFile.cs new file mode 100644 index 00000000..b20c6cb4 --- /dev/null +++ b/Myriad/Rest/Types/MultipartFile.cs @@ -0,0 +1,3 @@ +namespace Myriad.Rest.Types; + +public record MultipartFile(string Filename, Stream Data, string? Description); \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/CommandRequest.cs b/Myriad/Rest/Types/Requests/CommandRequest.cs new file mode 100644 index 00000000..3be47d0c --- /dev/null +++ b/Myriad/Rest/Types/Requests/CommandRequest.cs @@ -0,0 +1,10 @@ +using Myriad.Types; + +namespace Myriad.Rest.Types; + +public record ApplicationCommandRequest +{ + public string Name { get; init; } + public string Description { get; init; } + public List? Options { get; init; } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/CreateDmRequest.cs b/Myriad/Rest/Types/Requests/CreateDmRequest.cs new file mode 100644 index 00000000..a402d6a0 --- /dev/null +++ b/Myriad/Rest/Types/Requests/CreateDmRequest.cs @@ -0,0 +1,3 @@ +namespace Myriad.Rest.Types.Requests; + +public record CreateDmRequest(ulong RecipientId); \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/CreateWebhookRequest.cs b/Myriad/Rest/Types/Requests/CreateWebhookRequest.cs new file mode 100644 index 00000000..1721447e --- /dev/null +++ b/Myriad/Rest/Types/Requests/CreateWebhookRequest.cs @@ -0,0 +1,3 @@ +namespace Myriad.Rest.Types.Requests; + +public record CreateWebhookRequest(string Name); \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs b/Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs new file mode 100644 index 00000000..011ff652 --- /dev/null +++ b/Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs @@ -0,0 +1,14 @@ +using Myriad.Types; + +namespace Myriad.Rest.Types.Requests; + +public record ExecuteWebhookRequest +{ + public string? Content { get; init; } + public string? Username { get; init; } + public string? AvatarUrl { get; init; } + public Embed[] Embeds { get; init; } + public Sticker[] Stickers { get; init; } + public Message.Attachment[] Attachments { get; set; } + public AllowedMentions? AllowedMentions { get; init; } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/MessageEditRequest.cs b/Myriad/Rest/Types/Requests/MessageEditRequest.cs new file mode 100644 index 00000000..e662eb65 --- /dev/null +++ b/Myriad/Rest/Types/Requests/MessageEditRequest.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +using Myriad.Types; +using Myriad.Utils; + +namespace Myriad.Rest.Types.Requests; + +public record MessageEditRequest +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Content { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Embeds { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Flags { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional AllowedMentions { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Components { get; init; } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/MessageRequest.cs b/Myriad/Rest/Types/Requests/MessageRequest.cs new file mode 100644 index 00000000..d403f8ae --- /dev/null +++ b/Myriad/Rest/Types/Requests/MessageRequest.cs @@ -0,0 +1,14 @@ +using Myriad.Types; + +namespace Myriad.Rest.Types.Requests; + +public record MessageRequest +{ + public string? Content { get; set; } + public object? Nonce { get; set; } + public bool Tts { get; set; } + public AllowedMentions? AllowedMentions { get; set; } + public Embed[]? Embeds { get; set; } + public MessageComponent[]? Components { get; set; } + public Message.Reference? MessageReference { get; set; } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs b/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs new file mode 100644 index 00000000..2f9b4c56 --- /dev/null +++ b/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs @@ -0,0 +1,6 @@ +namespace Myriad.Rest.Types; + +public record ModifyGuildMemberRequest +{ + public string? Nick { get; init; } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/WebhookMessageEditRequest.cs b/Myriad/Rest/Types/Requests/WebhookMessageEditRequest.cs new file mode 100644 index 00000000..f9dc68c2 --- /dev/null +++ b/Myriad/Rest/Types/Requests/WebhookMessageEditRequest.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +using Myriad.Utils; + +namespace Myriad.Rest.Types.Requests; + +public record WebhookMessageEditRequest +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Content { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional AllowedMentions { get; init; } +} \ No newline at end of file diff --git a/Myriad/Serialization/JsonSerializerOptionsExtensions.cs b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs new file mode 100644 index 00000000..b1ec7945 --- /dev/null +++ b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Myriad.Serialization; + +public static class JsonSerializerOptionsExtensions +{ + public static JsonSerializerOptions ConfigureForMyriad(this JsonSerializerOptions opts) + { + opts.PropertyNamingPolicy = new JsonSnakeCaseNamingPolicy(); + opts.NumberHandling = JsonNumberHandling.AllowReadingFromString; + opts.IncludeFields = true; + + opts.Converters.Add(new PermissionSetJsonConverter()); + opts.Converters.Add(new ShardInfoJsonConverter()); + opts.Converters.Add(new OptionalConverterFactory()); + + return opts; + } +} \ No newline at end of file diff --git a/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs b/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs new file mode 100644 index 00000000..8fc596f4 --- /dev/null +++ b/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs @@ -0,0 +1,86 @@ +using System.Text; +using System.Text.Json; + +namespace Myriad.Serialization; + +// From https://github.com/J0rgeSerran0/JsonNamingPolicy/blob/master/JsonSnakeCaseNamingPolicy.cs, no NuGet :/ +public class JsonSnakeCaseNamingPolicy: JsonNamingPolicy +{ + private readonly string _separator = "_"; + + public override string ConvertName(string name) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(name)) return string.Empty; + + ReadOnlySpan spanName = name.Trim(); + + var stringBuilder = new StringBuilder(); + var addCharacter = true; + + var isPreviousSpace = false; + var isPreviousSeparator = false; + var isCurrentSpace = false; + var isNextLower = false; + var isNextUpper = false; + var isNextSpace = false; + + for (var position = 0; position < spanName.Length; position++) + { + if (position != 0) + { + isCurrentSpace = spanName[position] == 32; + isPreviousSpace = spanName[position - 1] == 32; + isPreviousSeparator = spanName[position - 1] == 95; + + if (position + 1 != spanName.Length) + { + isNextLower = spanName[position + 1] > 96 && spanName[position + 1] < 123; + isNextUpper = spanName[position + 1] > 64 && spanName[position + 1] < 91; + isNextSpace = spanName[position + 1] == 32; + } + + if (isCurrentSpace && + (isPreviousSpace || + isPreviousSeparator || + isNextUpper || + isNextSpace)) + { + addCharacter = false; + } + else + { + var isCurrentUpper = spanName[position] > 64 && spanName[position] < 91; + var isPreviousLower = spanName[position - 1] > 96 && spanName[position - 1] < 123; + var isPreviousNumber = spanName[position - 1] > 47 && spanName[position - 1] < 58; + + if (isCurrentUpper && + (isPreviousLower || + isPreviousNumber || + isNextLower || + isNextSpace || + isNextLower && !isPreviousSpace)) + { + stringBuilder.Append(_separator); + } + else + { + if (isCurrentSpace && + !isPreviousSpace && + !isNextSpace) + { + stringBuilder.Append(_separator); + addCharacter = false; + } + } + } + } + + if (addCharacter) + stringBuilder.Append(spanName[position]); + else + addCharacter = true; + } + + return stringBuilder.ToString().ToLower(); + } +} \ No newline at end of file diff --git a/Myriad/Serialization/JsonSnakeCaseStringEnumConverter.cs b/Myriad/Serialization/JsonSnakeCaseStringEnumConverter.cs new file mode 100644 index 00000000..087ad2b9 --- /dev/null +++ b/Myriad/Serialization/JsonSnakeCaseStringEnumConverter.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Myriad.Serialization; + +public class JsonSnakeCaseStringEnumConverter: JsonConverterFactory +{ + private readonly JsonStringEnumConverter _inner = new(new JsonSnakeCaseNamingPolicy()); + + public override bool CanConvert(Type typeToConvert) => + _inner.CanConvert(typeToConvert); + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => + _inner.CreateConverter(typeToConvert, options); +} \ No newline at end of file diff --git a/Myriad/Serialization/JsonStringConverter.cs b/Myriad/Serialization/JsonStringConverter.cs new file mode 100644 index 00000000..d0af8f0f --- /dev/null +++ b/Myriad/Serialization/JsonStringConverter.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Myriad.Serialization; + +public class JsonStringConverter: JsonConverter +{ + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = JsonSerializer.Deserialize(ref reader); + var inner = JsonSerializer.Deserialize(str!, typeToConvert, options); + return inner; + } + + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + { + var inner = JsonSerializer.Serialize(value, options); + writer.WriteStringValue(inner); + } +} \ No newline at end of file diff --git a/Myriad/Serialization/OptionalConverter.cs b/Myriad/Serialization/OptionalConverter.cs new file mode 100644 index 00000000..04c6c4ba --- /dev/null +++ b/Myriad/Serialization/OptionalConverter.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Myriad.Utils; + +namespace Myriad.Serialization; + +public class OptionalConverterFactory: JsonConverterFactory +{ + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var innerType = typeToConvert.GetGenericArguments()[0]; + return (JsonConverter?)Activator.CreateInstance( + typeof(Inner<>).MakeGenericType(innerType), + BindingFlags.Instance | BindingFlags.Public, + null, + null, + null); + } + + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + if (typeToConvert.GetGenericTypeDefinition() != typeof(Optional<>)) + return false; + + return true; + } + + public class Inner: JsonConverter> + { + public override Optional Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) + { + var inner = JsonSerializer.Deserialize(ref reader, options); + return new Optional(inner!); + } + + public override void Write(Utf8JsonWriter writer, Optional value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.HasValue ? value.GetValue() : default, typeof(T), options); + } + } +} \ No newline at end of file diff --git a/Myriad/Serialization/PermissionSetJsonConverter.cs b/Myriad/Serialization/PermissionSetJsonConverter.cs new file mode 100644 index 00000000..edb5f4d8 --- /dev/null +++ b/Myriad/Serialization/PermissionSetJsonConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Myriad.Types; + +namespace Myriad.Serialization; + +public class PermissionSetJsonConverter: JsonConverter +{ + public override PermissionSet Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = reader.GetString(); + if (str == null) return default; + + return (PermissionSet)ulong.Parse(str); + } + + public override void Write(Utf8JsonWriter writer, PermissionSet value, JsonSerializerOptions options) + { + writer.WriteStringValue(((ulong)value).ToString()); + } +} \ No newline at end of file diff --git a/Myriad/Serialization/ShardInfoJsonConverter.cs b/Myriad/Serialization/ShardInfoJsonConverter.cs new file mode 100644 index 00000000..db67052a --- /dev/null +++ b/Myriad/Serialization/ShardInfoJsonConverter.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Myriad.Gateway; + +namespace Myriad.Serialization; + +public class ShardInfoJsonConverter: JsonConverter +{ + public override ShardInfo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var arr = JsonSerializer.Deserialize(ref reader); + if (arr?.Length != 2) + throw new JsonException("Expected shard info as array of length 2"); + + return new ShardInfo(arr[0], arr[1]); + } + + public override void Write(Utf8JsonWriter writer, ShardInfo value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteNumberValue(value.ShardId); + writer.WriteNumberValue(value.NumShards); + writer.WriteEndArray(); + } +} \ No newline at end of file diff --git a/Myriad/Types/Activity.cs b/Myriad/Types/Activity.cs new file mode 100644 index 00000000..ce91c688 --- /dev/null +++ b/Myriad/Types/Activity.cs @@ -0,0 +1,17 @@ +namespace Myriad.Types; + +public record Activity +{ + public string Name { get; init; } + public ActivityType Type { get; init; } + public string? Url { get; init; } +} + +public enum ActivityType +{ + Game = 0, + Streaming = 1, + Listening = 2, + Custom = 4, + Competing = 5 +} \ No newline at end of file diff --git a/Myriad/Types/Application/Application.cs b/Myriad/Types/Application/Application.cs new file mode 100644 index 00000000..47ca4cff --- /dev/null +++ b/Myriad/Types/Application/Application.cs @@ -0,0 +1,24 @@ +namespace Myriad.Types; + +public record Application: ApplicationPartial +{ + public string Name { get; init; } + public string? Icon { get; init; } + public string Description { get; init; } + public string[]? RpcOrigins { get; init; } + public bool BotPublic { get; init; } + public bool BotRequireCodeGrant { get; init; } + public User Owner { get; init; } // TODO: docs specify this is "partial", what does that mean + public string Summary { get; init; } + public string VerifyKey { get; init; } + public ulong? GuildId { get; init; } + public ulong? PrimarySkuId { get; init; } + public string? Slug { get; init; } + public string? CoverImage { get; init; } +} + +public record ApplicationPartial +{ + public ulong Id { get; init; } + public int Flags { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Application/ApplicationCommand.cs b/Myriad/Types/Application/ApplicationCommand.cs new file mode 100644 index 00000000..097b222f --- /dev/null +++ b/Myriad/Types/Application/ApplicationCommand.cs @@ -0,0 +1,10 @@ +namespace Myriad.Types; + +public record ApplicationCommand +{ + public ulong Id { get; init; } + public ulong ApplicationId { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public ApplicationCommandOption[]? Options { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Application/ApplicationCommandInteractionData.cs b/Myriad/Types/Application/ApplicationCommandInteractionData.cs new file mode 100644 index 00000000..48c75906 --- /dev/null +++ b/Myriad/Types/Application/ApplicationCommandInteractionData.cs @@ -0,0 +1,10 @@ +namespace Myriad.Types; + +public record ApplicationCommandInteractionData +{ + public ulong? Id { get; init; } + public string? Name { get; init; } + public ApplicationCommandInteractionDataOption[]? Options { get; init; } + public string? CustomId { get; init; } + public ComponentType? ComponentType { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs b/Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs new file mode 100644 index 00000000..6787370c --- /dev/null +++ b/Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs @@ -0,0 +1,8 @@ +namespace Myriad.Types; + +public record ApplicationCommandInteractionDataOption +{ + public string Name { get; init; } + public object? Value { get; init; } + public ApplicationCommandInteractionDataOption[]? Options { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Application/ApplicationCommandOption.cs b/Myriad/Types/Application/ApplicationCommandOption.cs new file mode 100644 index 00000000..85533c7a --- /dev/null +++ b/Myriad/Types/Application/ApplicationCommandOption.cs @@ -0,0 +1,23 @@ +namespace Myriad.Types; + +public record ApplicationCommandOption(ApplicationCommandOption.OptionType Type, string Name, string Description) +{ + public enum OptionType + { + Subcommand = 1, + SubcommandGroup = 2, + String = 3, + Integer = 4, + Boolean = 5, + User = 6, + Channel = 7, + Role = 8 + } + + public bool Default { get; init; } + public bool Required { get; init; } + public Choice[]? Choices { get; init; } + public ApplicationCommandOption[]? Options { get; init; } + + public record Choice(string Name, object Value); +} \ No newline at end of file diff --git a/Myriad/Types/Application/Interaction.cs b/Myriad/Types/Application/Interaction.cs new file mode 100644 index 00000000..5969cfc4 --- /dev/null +++ b/Myriad/Types/Application/Interaction.cs @@ -0,0 +1,21 @@ +namespace Myriad.Types; + +public record Interaction +{ + public enum InteractionType + { + Ping = 1, + ApplicationCommand = 2, + MessageComponent = 3 + } + + public ulong Id { get; init; } + public InteractionType Type { get; init; } + public ApplicationCommandInteractionData? Data { get; init; } + public ulong GuildId { get; init; } + public ulong ChannelId { get; init; } + public GuildMember? Member { get; init; } + public User? User { get; init; } + public string Token { get; init; } + public Message? Message { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs b/Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs new file mode 100644 index 00000000..b9da3da3 --- /dev/null +++ b/Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +using Myriad.Rest.Types; +using Myriad.Utils; + +namespace Myriad.Types; + +public record InteractionApplicationCommandCallbackData +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Tts { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Content { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Embeds { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional AllowedMentions { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Flags { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Components { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Application/InteractionResponse.cs b/Myriad/Types/Application/InteractionResponse.cs new file mode 100644 index 00000000..1b6d929d --- /dev/null +++ b/Myriad/Types/Application/InteractionResponse.cs @@ -0,0 +1,16 @@ +namespace Myriad.Types; + +public record InteractionResponse +{ + public enum ResponseType + { + Pong = 1, + ChannelMessageWithSource = 4, + DeferredChannelMessageWithSource = 5, + DeferredUpdateMessage = 6, + UpdateMessage = 7 + } + + public ResponseType Type { get; init; } + public InteractionApplicationCommandCallbackData? Data { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Channel.cs b/Myriad/Types/Channel.cs new file mode 100644 index 00000000..bca93019 --- /dev/null +++ b/Myriad/Types/Channel.cs @@ -0,0 +1,45 @@ +namespace Myriad.Types; + +public record Channel +{ + public enum ChannelType + { + GuildText = 0, + Dm = 1, + GuildVoice = 2, + GroupDm = 3, + GuildCategory = 4, + GuildNews = 5, + GuildStore = 6, + GuildNewsThread = 10, + GuildPublicThread = 11, + GuildPrivateThread = 12, + GuildStageVoice = 13 + } + + public ulong Id { get; init; } + public ChannelType Type { get; init; } + public ulong? GuildId { get; init; } + public int? Position { get; init; } + public string? Name { get; init; } + public string? Topic { get; init; } + public bool? Nsfw { get; init; } + public ulong? ParentId { get; init; } + public Overwrite[]? PermissionOverwrites { get; init; } + public User[]? Recipients { get; init; } // NOTE: this may be null for stub channel objects + + public record Overwrite + { + public ulong Id { get; init; } + public OverwriteType Type { get; init; } + public PermissionSet Allow { get; init; } + public PermissionSet Deny { get; init; } + } + + public enum OverwriteType + { + Role = 0, + Member = 1 + } + +} \ No newline at end of file diff --git a/Myriad/Types/Component/ButtonStyle.cs b/Myriad/Types/Component/ButtonStyle.cs new file mode 100644 index 00000000..06b05523 --- /dev/null +++ b/Myriad/Types/Component/ButtonStyle.cs @@ -0,0 +1,10 @@ +namespace Myriad.Types; + +public enum ButtonStyle +{ + Primary = 1, + Secondary = 2, + Success = 3, + Danger = 4, + Link = 5 +} \ No newline at end of file diff --git a/Myriad/Types/Component/ComponentType.cs b/Myriad/Types/Component/ComponentType.cs new file mode 100644 index 00000000..0b10a756 --- /dev/null +++ b/Myriad/Types/Component/ComponentType.cs @@ -0,0 +1,7 @@ +namespace Myriad.Types; + +public enum ComponentType +{ + ActionRow = 1, + Button = 2 +} \ No newline at end of file diff --git a/Myriad/Types/Component/MessageComponent.cs b/Myriad/Types/Component/MessageComponent.cs new file mode 100644 index 00000000..2240a2fe --- /dev/null +++ b/Myriad/Types/Component/MessageComponent.cs @@ -0,0 +1,13 @@ +namespace Myriad.Types; + +public record MessageComponent +{ + public ComponentType Type { get; init; } + public ButtonStyle? Style { get; init; } + public string? Label { get; init; } + public Emoji? Emoji { get; init; } + public string? CustomId { get; init; } + public string? Url { get; init; } + public bool? Disabled { get; init; } + public MessageComponent[]? Components { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Embed.cs b/Myriad/Types/Embed.cs new file mode 100644 index 00000000..b71b633a --- /dev/null +++ b/Myriad/Types/Embed.cs @@ -0,0 +1,61 @@ +namespace Myriad.Types; + +public record Embed +{ + public string? Title { get; init; } + public string? Type { get; init; } + public string? Description { get; init; } + public string? Url { get; init; } + public string? Timestamp { get; init; } + public uint? Color { get; init; } + public EmbedFooter? Footer { get; init; } + public EmbedImage? Image { get; init; } + public EmbedThumbnail? Thumbnail { get; init; } + public EmbedVideo? Video { get; init; } + public EmbedProvider? Provider { get; init; } + public EmbedAuthor? Author { get; init; } + public Field[]? Fields { get; init; } + + public record EmbedFooter( + string Text, + string? IconUrl = null, + string? ProxyIconUrl = null + ); + + public record EmbedImage( + string? Url, + uint? Width = null, + uint? Height = null + ); + + public record EmbedThumbnail( + string? Url, + string? ProxyUrl = null, + uint? Width = null, + uint? Height = null + ); + + public record EmbedVideo( + string? Url, + uint? Width = null, + uint? Height = null + ); + + public record EmbedProvider( + string? Name, + string? Url + ); + + public record EmbedAuthor( + string? Name = null, + string? Url = null, + string? IconUrl = null, + string? ProxyIconUrl = null + ); + + public record Field( + string Name, + string Value, + bool Inline = false + ); +} \ No newline at end of file diff --git a/Myriad/Types/Emoji.cs b/Myriad/Types/Emoji.cs new file mode 100644 index 00000000..2e41b90a --- /dev/null +++ b/Myriad/Types/Emoji.cs @@ -0,0 +1,8 @@ +namespace Myriad.Types; + +public record Emoji +{ + public ulong? Id { get; init; } + public string? Name { get; init; } + public bool? Animated { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Gateway/GatewayInfo.cs b/Myriad/Types/Gateway/GatewayInfo.cs new file mode 100644 index 00000000..ad554a47 --- /dev/null +++ b/Myriad/Types/Gateway/GatewayInfo.cs @@ -0,0 +1,12 @@ +namespace Myriad.Types; + +public record GatewayInfo +{ + public string Url { get; init; } + + public record Bot: GatewayInfo + { + public int Shards { get; init; } + public SessionStartLimit SessionStartLimit { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Gateway/SessionStartLimit.cs b/Myriad/Types/Gateway/SessionStartLimit.cs new file mode 100644 index 00000000..6a1ad4cc --- /dev/null +++ b/Myriad/Types/Gateway/SessionStartLimit.cs @@ -0,0 +1,9 @@ +namespace Myriad.Types; + +public record SessionStartLimit +{ + public int Total { get; init; } + public int Remaining { get; init; } + public int ResetAfter { get; init; } + public int MaxConcurrency { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Guild.cs b/Myriad/Types/Guild.cs new file mode 100644 index 00000000..2e44237b --- /dev/null +++ b/Myriad/Types/Guild.cs @@ -0,0 +1,30 @@ +namespace Myriad.Types; + +public enum PremiumTier +{ + NONE, + TIER_1, + TIER_2, + TIER_3 +} + +public record Guild +{ + public ulong Id { get; init; } + public string Name { get; init; } + public string? Icon { get; init; } + public string? Splash { get; init; } + public string? DiscoverySplash { get; init; } + public bool? Owner { get; init; } + public ulong OwnerId { get; init; } + public string Region { get; init; } + public ulong? AfkChannelId { get; init; } + public int AfkTimeout { get; init; } + public bool? WidgetEnabled { get; init; } + public ulong? WidgetChannelId { get; init; } + public int VerificationLevel { get; init; } + public PremiumTier PremiumTier { get; init; } + + public Role[] Roles { get; init; } + public string[] Features { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/GuildMember.cs b/Myriad/Types/GuildMember.cs new file mode 100644 index 00000000..097c8938 --- /dev/null +++ b/Myriad/Types/GuildMember.cs @@ -0,0 +1,14 @@ +namespace Myriad.Types; + +public record GuildMember: GuildMemberPartial +{ + public User User { get; init; } +} + +public record GuildMemberPartial +{ + public string? Avatar { get; init; } + public string? Nick { get; init; } + public ulong[] Roles { get; init; } + public string JoinedAt { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Message.cs b/Myriad/Types/Message.cs new file mode 100644 index 00000000..55928de9 --- /dev/null +++ b/Myriad/Types/Message.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; + +using Myriad.Utils; + +namespace Myriad.Types; + +public record Message +{ + [Flags] + public enum MessageFlags + { + Crossposted = 1 << 0, + IsCrosspost = 1 << 1, + SuppressEmbeds = 1 << 2, + SourceMessageDeleted = 1 << 3, + Urgent = 1 << 4, + Ephemeral = 1 << 6 + } + + public enum MessageType + { + Default = 0, + RecipientAdd = 1, + RecipientRemove = 2, + Call = 3, + ChannelNameChange = 4, + ChannelIconChange = 5, + ChannelPinnedMessage = 6, + GuildMemberJoin = 7, + UserPremiumGuildSubscription = 8, + UserPremiumGuildSubscriptionTier1 = 9, + UserPremiumGuildSubscriptionTier2 = 10, + UserPremiumGuildSubscriptionTier3 = 11, + ChannelFollowAdd = 12, + GuildDiscoveryDisqualified = 14, + GuildDiscoveryRequalified = 15, + Reply = 19, + ApplicationCommand = 20, + ThreadStarterMessage = 21, + GuildInviteReminder = 22 + } + + public ulong Id { get; init; } + public ulong ChannelId { get; init; } + public ulong? GuildId { get; init; } + public MessageActivity? Activity { get; init; } + public User Author { get; init; } + public string? Content { get; init; } + public string? Timestamp { get; init; } + public string? EditedTimestamp { get; init; } + public bool Tts { get; init; } + public bool MentionEveryone { get; init; } + public User.Extra[] Mentions { get; init; } + public ulong[] MentionRoles { get; init; } + + public Attachment[] Attachments { get; init; } + public Embed[]? Embeds { get; init; } + public Sticker[]? StickerItems { get; init; } + public Sticker[]? Stickers { get; init; } + public Reaction[] Reactions { get; init; } + public bool Pinned { get; init; } + public ulong? WebhookId { get; init; } + public ulong? ApplicationId { get; init; } + public MessageType Type { get; init; } + public Reference? MessageReference { get; set; } + public MessageFlags Flags { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional ReferencedMessage { get; init; } + + public MessageComponent[]? Components { get; init; } + + public record Reference(ulong? GuildId, ulong? ChannelId, ulong? MessageId); + + public record MessageActivity(int Type, string PartyId); + + public record Attachment + { + public ulong Id { get; init; } + public string? Description { get; init; } + public string Filename { get; init; } + public int Size { get; init; } + public string Url { get; init; } + public string ProxyUrl { get; init; } + public int? Width { get; init; } + public int? Height { get; init; } + } + + public record Reaction + { + public int Count { get; init; } + public bool Me { get; init; } + public Emoji Emoji { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/PermissionSet.cs b/Myriad/Types/PermissionSet.cs new file mode 100644 index 00000000..2873a3ff --- /dev/null +++ b/Myriad/Types/PermissionSet.cs @@ -0,0 +1,44 @@ +namespace Myriad.Types; + +[Flags] +public enum PermissionSet: ulong +{ + CreateInvite = 0x1, + KickMembers = 0x2, + BanMembers = 0x4, + Administrator = 0x8, + ManageChannels = 0x10, + ManageGuild = 0x20, + AddReactions = 0x40, + ViewAuditLog = 0x80, + PrioritySpeaker = 0x100, + Stream = 0x200, + ViewChannel = 0x400, + SendMessages = 0x800, + SendTtsMessages = 0x1000, + ManageMessages = 0x2000, + EmbedLinks = 0x4000, + AttachFiles = 0x8000, + ReadMessageHistory = 0x10000, + MentionEveryone = 0x20000, + UseExternalEmojis = 0x40000, + ViewGuildInsights = 0x80000, + Connect = 0x100000, + Speak = 0x200000, + MuteMembers = 0x400000, + DeafenMembers = 0x800000, + MoveMembers = 0x1000000, + UseVad = 0x2000000, + ChangeNickname = 0x4000000, + ManageNicknames = 0x8000000, + ManageRoles = 0x10000000, + ManageWebhooks = 0x20000000, + ManageEmojis = 0x40000000, + + // Special: + None = 0, + All = 0x7FFFFFFF, + + Dm = ViewChannel | SendMessages | ReadMessageHistory | AddReactions | AttachFiles | EmbedLinks | + UseExternalEmojis | Connect | Speak | UseVad +} \ No newline at end of file diff --git a/Myriad/Types/Role.cs b/Myriad/Types/Role.cs new file mode 100644 index 00000000..4347b117 --- /dev/null +++ b/Myriad/Types/Role.cs @@ -0,0 +1,13 @@ +namespace Myriad.Types; + +public record Role +{ + public ulong Id { get; init; } + public string Name { get; init; } + public uint Color { get; init; } + public bool Hoist { get; init; } + public int Position { get; init; } + public PermissionSet Permissions { get; init; } + public bool Managed { get; init; } + public bool Mentionable { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/Sticker.cs b/Myriad/Types/Sticker.cs new file mode 100644 index 00000000..c79c5c23 --- /dev/null +++ b/Myriad/Types/Sticker.cs @@ -0,0 +1,29 @@ +namespace Myriad.Types; + +public record Sticker +{ + public enum StickerType + { + STANDARD = 1, + GUILD = 2, + } + + public enum StickerFormatType + { + PNG = 1, + APNG = 2, + LOTTIE = 3, + } + + public ulong Id { get; init; } + public StickerType Type { get; init; } + public ulong? PackId { get; init; } + public string Name { get; init; } + public string? Description { get; init; } + public string Tags { get; init; } + public string Asset { get; init; } + public bool Available { get; init; } + public ulong? GuildId { get; init; } + public User? User { get; init; } + public int? SortValue { get; init; } +} \ No newline at end of file diff --git a/Myriad/Types/User.cs b/Myriad/Types/User.cs new file mode 100644 index 00000000..359ab1fb --- /dev/null +++ b/Myriad/Types/User.cs @@ -0,0 +1,35 @@ +namespace Myriad.Types; + +public record User +{ + [Flags] + public enum Flags + { + DiscordEmployee = 1 << 0, + PartneredServerOwner = 1 << 1, + HypeSquadEvents = 1 << 2, + BugHunterLevel1 = 1 << 3, + HouseBravery = 1 << 6, + HouseBrilliance = 1 << 7, + HouseBalance = 1 << 8, + EarlySupporter = 1 << 9, + TeamUser = 1 << 10, + System = 1 << 12, + BugHunterLevel2 = 1 << 14, + VerifiedBot = 1 << 16, + EarlyVerifiedBotDeveloper = 1 << 17 + } + + public ulong Id { get; init; } + public string Username { get; init; } + public string Discriminator { get; init; } + public string? Avatar { get; init; } + public bool Bot { get; init; } + public bool? System { get; init; } + public Flags PublicFlags { get; init; } + + public record Extra: User + { + public GuildMemberPartial? Member { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Webhook.cs b/Myriad/Types/Webhook.cs new file mode 100644 index 00000000..a3688860 --- /dev/null +++ b/Myriad/Types/Webhook.cs @@ -0,0 +1,20 @@ +namespace Myriad.Types; + +public record Webhook +{ + public ulong Id { get; init; } + public WebhookType Type { get; init; } + public ulong? GuildId { get; init; } + public ulong ChannelId { get; init; } + public User? User { get; init; } + public string? Name { get; init; } + public string? Avatar { get; init; } + public string? Token { get; init; } + public ulong? ApplicationId { get; init; } +} + +public enum WebhookType +{ + Incoming = 1, + ChannelFollower = 2 +} \ No newline at end of file diff --git a/Myriad/Utils/Optional.cs b/Myriad/Utils/Optional.cs new file mode 100644 index 00000000..182b015f --- /dev/null +++ b/Myriad/Utils/Optional.cs @@ -0,0 +1,25 @@ +namespace Myriad.Utils; + +public interface IOptional +{ + object? GetValue(); +} + +public readonly struct Optional: IOptional +{ + public Optional(T value) + { + HasValue = true; + Value = value; + } + + public bool HasValue { get; } + public object? GetValue() => Value; + + public T Value { get; } + + public static implicit operator Optional(T value) => new(value); + + public static Optional Some(T value) => new(value); + public static Optional None() => default; +} \ No newline at end of file diff --git a/Myriad/packages.lock.json b/Myriad/packages.lock.json new file mode 100644 index 00000000..a0c90f89 --- /dev/null +++ b/Myriad/packages.lock.json @@ -0,0 +1,284 @@ +{ + "version": 1, + "dependencies": { + "net6.0": { + "Google.Protobuf": { + "type": "Direct", + "requested": "[3.13.0, )", + "resolved": "3.13.0", + "contentHash": "/6VgKCh0P59x/rYsBkCvkUanF0TeUYzwV9hzLIWgt23QRBaKHoxaaMkidEWhKibLR88c3PVCXyyrx9Xlb+Ne6w==", + "dependencies": { + "System.Memory": "4.5.2", + "System.Runtime.CompilerServices.Unsafe": "4.5.2" + } + }, + "Grpc.Net.ClientFactory": { + "type": "Direct", + "requested": "[2.32.0, )", + "resolved": "2.32.0", + "contentHash": "ixqSWxPK49P+5z6M2dDBHca0k+sXFe2KHHTJK3P+YXp6QOTHv5CHxNdaW8GrFF34Eh1FJ56Q2ADe383+FEAp6Q==", + "dependencies": { + "Grpc.Net.Client": "2.32.0", + "Microsoft.Extensions.Http": "3.0.3" + } + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.37.0, )", + "resolved": "2.37.0", + "contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg==" + }, + "Polly": { + "type": "Direct", + "requested": "[7.2.1, )", + "resolved": "7.2.1", + "contentHash": "Od8SnPlpQr+PuAS0YzY3jgtzaDNknlIuAaldN2VEIyTvm/wCg22C5nUkUV1BEG8rIsub5RFMoV/NEQ0tM/+7Uw==" + }, + "Polly.Contrib.WaitAndRetry": { + "type": "Direct", + "requested": "[1.1.1, )", + "resolved": "1.1.1", + "contentHash": "1MUQLiSo4KDkQe6nzQRhIU05lm9jlexX5BVsbuw0SL82ynZ+GzAHQxJVDPVBboxV37Po3SG077aX8DuSy8TkaA==" + }, + "Serilog": { + "type": "Direct", + "requested": "[2.10.0, )", + "resolved": "2.10.0", + "contentHash": "+QX0hmf37a0/OZLxM3wL7V6/ADvC1XihXN4Kq/p6d8lCPfgkRdiuhbWlMaFjR9Av0dy5F0+MBeDmDdRZN/YwQA==" + }, + "StackExchange.Redis": { + "type": "Direct", + "requested": "[2.2.88, )", + "resolved": "2.2.88", + "contentHash": "JJi1jcO3/ZiamBhlsC/TR8aZmYf+nqpGzMi0HRRCy5wJkUPmMnRp0kBA6V84uhU8b531FHSdTDaFCAyCUJomjA==", + "dependencies": { + "Pipelines.Sockets.Unofficial": "2.2.0", + "System.Diagnostics.PerformanceCounter": "5.0.0" + } + }, + "System.Linq.Async": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.32.0", + "contentHash": "t9H6P/oYA4ZQI4fWq4eEwq2GmMNqmOSRfz5+YIat7pQuFmz1hRC2Vq/fL9ZVV1mjd5kHqBlhupMdlsBOsaxeEw==", + "dependencies": { + "System.Memory": "4.5.3" + } + }, + "Grpc.Net.Client": { + "type": "Transitive", + "resolved": "2.32.0", + "contentHash": "T4lKl51ahaSprLcgoZvgn8zYwh834DpaPnrDs6jBRdipL2NHIAC0rPeE7UyzDp/lzv4Xll2tw1u65Fg9ckvErg==", + "dependencies": { + "Grpc.Net.Common": "2.32.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.3", + "System.Diagnostics.DiagnosticSource": "4.5.1" + } + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.32.0", + "contentHash": "vDsgy6fs+DlsylppjK9FBGTMMUe8vfAmaURV7ZTurM27itr8qBwymgqmwnVB2hcP1q35NqKx2NvPGe5S2IEnDw==", + "dependencies": { + "Grpc.Core.Api": "2.32.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "0F/MLd7yOSjhswQSFO6tkTREHxBffE/AS9gnvtx1jVFaKNdJPEj+2KOdREmYZ4Orpvf4nwXGwbRpX5SLlwIPEw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "3.0.3" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "ND+ka7njp3HgVJCVn0YgpuxbFWBOCkcQaK+UBGJNseDhjz6I/qpXmCqLK+nXSzxU7cdscFWnUJS6wjEEEkMvSQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "3.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "hRmuReZgWqWqko4RXaGd/DP9L7380+HafHgbR5CMc7AZYmoLpUmeV8O8sgZqJONCbzg1q0Sz8U8Gy99eETpGPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "3.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "Vu59TuHl3zoRI8vwK6gQL2EbWI2Qf/uBHFkSJXb4pgNvW7g8yK6Gn3v1bXDIKbMKEneTApriHfCVde0O314K+g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "Wb1ejBzHhCvp7VMr+M7vlHoXb68mJ89IHj4L+TzL8yA+X7Iz2UTAEkl8aIbhRloroYJw5zvlIPtKF5uA4wFlxw==" + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "dcyB8szIcSynjVZRuFgqkZpPgTc5zeRSj1HMXSmNqWbHYKiPYJl8ZQgBHz6wmZNSUUNGpCs5uxUg8DZHHDC1Ew==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.3", + "Microsoft.Extensions.Logging": "3.0.3", + "Microsoft.Extensions.Options": "3.0.3" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "uAZppu6kWIgS+VtVjqmhh+k3bMztwWQR5HYxI++Cn5Kz5m099g0KJ+krUrckaZP9NqIplQu63tPR5YpNWnjLuw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "3.0.3", + "Microsoft.Extensions.DependencyInjection": "3.0.3", + "Microsoft.Extensions.Logging.Abstractions": "3.0.3", + "Microsoft.Extensions.Options": "3.0.3" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "m2Jyi/MEn043WMI1I6J1ALuCThktZ93rd7eqzYeLmMcA0bdZC+TBVl0LuEbEWM01dWeeBjOoagjNwQTzOi2r6A==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "cvP/w0kyT9TmrDPMY2ZHJBWx+gRH0jHKaJPkzN47UBpLLC4KbqVU5AoCMK47+ZChlINhqJX2WTflbLe5KufD/A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.3", + "Microsoft.Extensions.Primitives": "3.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "eJuAFVIH9zUZ7j7tCSbJD+tS0dueENIerwoGxFL8RYqCmbEqQ7wVOG+mt2mZAbEpnMPsGl1Fc/HIhWpB9ftwhg==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Bh6blKG8VAKvXiLe2L+sEsn62nc1Ij34MrNxepD2OCrS5cpCwQa9MeLyhVQPQ/R4Wlzwuy6wMK8hLb11QPDRsQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "7hzHplEIVOGBl5zOQZGX/DiJDHjq+RVRVrYgDiqXb6RriqWAdacXxp+XO9WSrATCEXyNOUOQg9aqQArsjase/A==", + "dependencies": { + "System.IO.Pipelines": "5.0.0" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "aM7cbfEfVNlEEOj3DsZP+2g9NRwbkyiAv2isQEzw7pnkDg9ekCU2m1cdJLM02Uq691OaCS91tooaxcEn8d0q5w==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "5.0.0", + "System.Security.Permissions": "5.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "zCno/m44ymWhgLFh7tELDG9587q0l/EynPM0m4KgLaWQbz/TEKvNRX2YT5ip2qXW/uayifQ2ZqbnErsKJ4lYrQ==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "kcQWWtGVC3MWMNXdMDWfrmIlFZZ2OdoeT6pSNVRtk9+Sa7jwdPiMlNwb0ZQcS7NRlT92pCfmjRtkSWUW3RAKwg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "Microsoft.Win32.Registry": "5.0.0", + "System.Configuration.ConfigurationManager": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SztFwAnpfKC8+sEKXAFxCBWhKQaEd97EiOL7oZJZP56zbqnLpmxACWA8aGseaUExciuEAUuR9dY8f7HkTRAdnw==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "5.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "irMYm3vhVgRsYvHTU5b2gsT2CwT/SMM6LZFzuJjpIvT5Z4CshxNsaoBC1X/LltwuR3Opp8d6jOS/60WwOb7Q2Q==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.2", + "contentHash": "wprSFgext8cwqymChhrBLu62LMg/1u92bU+VOwyfBimSPVFXtsNqEWC92Pf9ofzJFlk4IHmJA75EDJn1b2goAQ==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "HGxMSAFAPLNoxBvSfW08vHde0F9uh7BjASwu6JF9JnXuEPhCY3YUqURn0+bQV/4UWeaqymmrHWV+Aw9riQCtCA==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "uE8juAhEkp7KDBCdjDIE3H9R1HJuEHqeqX8nLX9gmYKWwsqk3T5qZlPx8qle5DPKimC/Fy3AFTdV7HamgCh9qQ==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Windows.Extensions": "5.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "c1ho9WU9ZxMZawML+ssPKZfdnrg/OjR3pe0m9v8230z3acqphwvPJqzAkH54xRYm5ntZHGG1EPP3sux9H3qSPg==", + "dependencies": { + "System.Drawing.Common": "5.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/PluralKit.API/APIJsonExt.cs b/PluralKit.API/APIJsonExt.cs new file mode 100644 index 00000000..91386bc3 --- /dev/null +++ b/PluralKit.API/APIJsonExt.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using NodaTime; + +using PluralKit.Core; + +namespace PluralKit.API; + +public static class APIJsonExt +{ + public static JObject ToJson(this ModelRepository.Counts counts, int guildCount, int channelCount) + { + var o = new JObject(); + + o.Add("system_count", counts.SystemCount); + o.Add("member_count", counts.MemberCount); + o.Add("group_count", counts.GroupCount); + o.Add("switch_count", counts.SwitchCount); + o.Add("message_count", counts.MessageCount); + + // Discord statistics + o.Add("guild_count", guildCount); + o.Add("channel_count", channelCount); + + return o; + } +} + +public struct FrontersReturnNew +{ + [JsonProperty("id")] public Guid Uuid { get; set; } + [JsonProperty("timestamp")] public Instant Timestamp { get; set; } + [JsonProperty("members")] public IEnumerable Members { get; set; } +} + +public struct SwitchesReturnNew +{ + [JsonProperty("id")] public Guid Uuid { get; set; } + [JsonProperty("timestamp")] public Instant Timestamp { get; set; } + [JsonProperty("members")] public IEnumerable Members { get; set; } +} \ No newline at end of file diff --git a/PluralKit.API/ApiConfig.cs b/PluralKit.API/ApiConfig.cs index 0568fe54..b3946168 100644 --- a/PluralKit.API/ApiConfig.cs +++ b/PluralKit.API/ApiConfig.cs @@ -1,7 +1,8 @@ -namespace PluralKit.API +namespace PluralKit.API; + +public class ApiConfig { - public class ApiConfig - { - public int Port { get; set; } = 5000; - } + public int Port { get; set; } = 5000; + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } } \ No newline at end of file diff --git a/PluralKit.API/Authentication/AuthExt.cs b/PluralKit.API/Authentication/AuthExt.cs deleted file mode 100644 index 1d259eec..00000000 --- a/PluralKit.API/Authentication/AuthExt.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Security.Claims; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public static class AuthExt - { - public static SystemId CurrentSystem(this ClaimsPrincipal user) - { - var claim = user.FindFirst(PKClaims.SystemId); - if (claim == null) throw new ArgumentException("User is unauthorized"); - - if (int.TryParse(claim.Value, out var id)) - return new SystemId(id); - throw new ArgumentException("User has non-integer system ID claim"); - } - - public static LookupContext ContextFor(this ClaimsPrincipal user, PKSystem system) - { - if (!user.Identity.IsAuthenticated) return LookupContext.API; - return system.Id == user.CurrentSystem() ? LookupContext.ByOwner : LookupContext.API; - } - - public static LookupContext ContextFor(this ClaimsPrincipal user, PKMember member) - { - if (!user.Identity.IsAuthenticated) return LookupContext.API; - return member.System == user.CurrentSystem() ? LookupContext.ByOwner : LookupContext.API; - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Authentication/PKClaims.cs b/PluralKit.API/Authentication/PKClaims.cs deleted file mode 100644 index 2ab31e1a..00000000 --- a/PluralKit.API/Authentication/PKClaims.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace PluralKit.API -{ - public class PKClaims - { - public const string SystemId = "PluralKit:SystemId"; - } -} \ No newline at end of file diff --git a/PluralKit.API/Authentication/SystemTokenAuthenticationHandler.cs b/PluralKit.API/Authentication/SystemTokenAuthenticationHandler.cs deleted file mode 100644 index 8080f60a..00000000 --- a/PluralKit.API/Authentication/SystemTokenAuthenticationHandler.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Linq; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Threading.Tasks; - -using Dapper; - -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public class SystemTokenAuthenticationHandler: AuthenticationHandler - { - private readonly IDatabase _db; - - public SystemTokenAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IDatabase db): base(options, logger, encoder, clock) - { - _db = db; - } - - protected override async Task HandleAuthenticateAsync() - { - if (!Request.Headers.ContainsKey("Authorization")) - return AuthenticateResult.NoResult(); - - var token = Request.Headers["Authorization"].FirstOrDefault(); - var systemId = await _db.Execute(c => c.QuerySingleOrDefaultAsync("select id from systems where token = @token", new { token })); - if (systemId == null) return AuthenticateResult.Fail("Invalid system token"); - - var claims = new[] {new Claim(PKClaims.SystemId, systemId.Value.Value.ToString())}; - var identity = new ClaimsIdentity(claims, Scheme.Name); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, Scheme.Name); - ticket.Properties.IsPersistent = false; - ticket.Properties.AllowRefresh = false; - return AuthenticateResult.Success(ticket); - } - - public class Opts: AuthenticationSchemeOptions - { - - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Authorization/MemberOwnerHandler.cs b/PluralKit.API/Authorization/MemberOwnerHandler.cs deleted file mode 100644 index a212ad2c..00000000 --- a/PluralKit.API/Authorization/MemberOwnerHandler.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Authorization; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public class MemberOwnerHandler: AuthorizationHandler { - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, - OwnSystemRequirement requirement, PKMember resource) - { - if (!context.User.Identity.IsAuthenticated) return Task.CompletedTask; - if (resource.System == context.User.CurrentSystem()) - context.Succeed(requirement); - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Authorization/MemberPrivacyHandler.cs b/PluralKit.API/Authorization/MemberPrivacyHandler.cs deleted file mode 100644 index 41437ed6..00000000 --- a/PluralKit.API/Authorization/MemberPrivacyHandler.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Authorization; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public class MemberPrivacyHandler: AuthorizationHandler, PKMember> - { - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, - PrivacyRequirement requirement, PKMember resource) - { - var level = requirement.Mapper(resource); - var ctx = context.User.ContextFor(resource); - if (level.CanAccess(ctx)) - context.Succeed(requirement); - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Authorization/OwnSystemRequirement.cs b/PluralKit.API/Authorization/OwnSystemRequirement.cs deleted file mode 100644 index e292db75..00000000 --- a/PluralKit.API/Authorization/OwnSystemRequirement.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace PluralKit.API -{ - public class OwnSystemRequirement: IAuthorizationRequirement { } -} \ No newline at end of file diff --git a/PluralKit.API/Authorization/PrivacyRequirement.cs b/PluralKit.API/Authorization/PrivacyRequirement.cs deleted file mode 100644 index ef9312e1..00000000 --- a/PluralKit.API/Authorization/PrivacyRequirement.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -using Microsoft.AspNetCore.Authorization; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public class PrivacyRequirement: IAuthorizationRequirement - { - public readonly Func Mapper; - - public PrivacyRequirement(Func mapper) - { - Mapper = mapper; - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Authorization/SystemOwnerHandler.cs b/PluralKit.API/Authorization/SystemOwnerHandler.cs deleted file mode 100644 index 72cfede7..00000000 --- a/PluralKit.API/Authorization/SystemOwnerHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Authorization; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public class SystemOwnerHandler: AuthorizationHandler - { - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, - OwnSystemRequirement requirement, PKSystem resource) - { - if (!context.User.Identity.IsAuthenticated) return Task.CompletedTask; - if (resource.Id == context.User.CurrentSystem()) - context.Succeed(requirement); - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Authorization/SystemPrivacyHandler.cs b/PluralKit.API/Authorization/SystemPrivacyHandler.cs deleted file mode 100644 index 469324b1..00000000 --- a/PluralKit.API/Authorization/SystemPrivacyHandler.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Authorization; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public class SystemPrivacyHandler: AuthorizationHandler, PKSystem> - { - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, - PrivacyRequirement requirement, PKSystem resource) - { - var level = requirement.Mapper(resource); - var ctx = context.User.ContextFor(resource); - if (level.CanAccess(ctx)) - context.Succeed(requirement); - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs new file mode 100644 index 00000000..18e8593a --- /dev/null +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -0,0 +1,123 @@ +using System.Text.RegularExpressions; + +using Microsoft.AspNetCore.Mvc; + +using PluralKit.Core; + +namespace PluralKit.API; + +public class PKControllerBase: ControllerBase +{ + private readonly Guid _requestId = Guid.NewGuid(); + private readonly Regex _shortIdRegex = new("^[a-z]{5}$"); + private readonly Regex _snowflakeRegex = new("^[0-9]{17,19}$"); + + private List? _memberLookupCache { get; set; } + private List? _groupLookupCache { get; set; } + + protected readonly ApiConfig _config; + protected readonly IDatabase _db; + protected readonly ModelRepository _repo; + protected readonly DispatchService _dispatch; + + public PKControllerBase(IServiceProvider svc) + { + _config = svc.GetRequiredService(); + _db = svc.GetRequiredService(); + _repo = svc.GetRequiredService(); + _dispatch = svc.GetRequiredService(); + } + + protected Task ResolveSystem(string systemRef) + { + if (systemRef == "@me") + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) + throw Errors.GenericAuthError; + return _repo.GetSystem((SystemId)systemId); + } + + if (Guid.TryParse(systemRef, out var guid)) + return _repo.GetSystemByGuid(guid); + + if (_snowflakeRegex.IsMatch(systemRef)) + return _repo.GetSystemByAccount(ulong.Parse(systemRef)); + + if (_shortIdRegex.IsMatch(systemRef)) + return _repo.GetSystemByHid(systemRef); + + return Task.FromResult(null); + } + + protected async Task ResolveMember(string memberRef, bool cache = false) + { + if (cache) + { + if (_memberLookupCache == null) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) + throw new Exception("Authenticated user must not be null to use lookup cache!"); + + _memberLookupCache = await _repo.GetSystemMembers((SystemId)systemId).ToListAsync(); + } + + return _memberLookupCache.FirstOrDefault(x => x.Hid == memberRef || x.Uuid.ToString() == memberRef); + } + + if (Guid.TryParse(memberRef, out var guid)) + return await _repo.GetMemberByGuid(guid); + + if (_shortIdRegex.IsMatch(memberRef)) + return await _repo.GetMemberByHid(memberRef); + + return null; + } + + protected async Task ResolveGroup(string groupRef, bool cache = false) + { + if (cache) + { + if (_groupLookupCache == null) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) + throw new Exception("Authenticated user must not be null to use lookup cache!"); + + _groupLookupCache = await _repo.GetSystemGroups((SystemId)systemId).ToListAsync(); + } + + return _groupLookupCache.FirstOrDefault(x => x.Hid == groupRef || x.Uuid.ToString() == groupRef); + } + + if (Guid.TryParse(groupRef, out var guid)) + return await _repo.GetGroupByGuid(guid); + + if (_shortIdRegex.IsMatch(groupRef)) + return await _repo.GetGroupByHid(groupRef); + + return null; + } + + protected LookupContext ContextFor(PKSystem system) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) return LookupContext.ByNonOwner; + return (SystemId)systemId == system.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner; + } + + protected LookupContext ContextFor(PKMember member) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) return LookupContext.ByNonOwner; + return (SystemId)systemId == member.System ? LookupContext.ByOwner : LookupContext.ByNonOwner; + } + + protected LookupContext ContextFor(PKGroup group) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) return LookupContext.ByNonOwner; + return (SystemId)systemId == group.System ? LookupContext.ByOwner : LookupContext.ByNonOwner; + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/PrivateController.cs b/PluralKit.API/Controllers/PrivateController.cs new file mode 100644 index 00000000..1043e5a4 --- /dev/null +++ b/PluralKit.API/Controllers/PrivateController.cs @@ -0,0 +1,191 @@ +using Microsoft.AspNetCore.Mvc; + +using SqlKata; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using PluralKit.Core; + +namespace PluralKit.API; + +// Internal API definitions +// I would prefer if you do not use any of these APIs in your own integrations. +// It is unstable and subject to change at any time (which is why it's not versioned) + +// If for some reason you do need access to something defined here, +// let us know in #api-support on the support server (https://discord.com/invite/PczBt78) and I'll see if it can be made public + +[ApiController] +[Route("private")] +public class PrivateController: PKControllerBase +{ + private readonly RedisService _redis; + + public PrivateController(IServiceProvider svc) : base(svc) + { + _redis = svc.GetRequiredService(); + } + + [HttpGet("meta")] + public async Task> Meta() + { + var db = _redis.Connection.GetDatabase(); + var redisInfo = await db.HashGetAllAsync("pluralkit:shardstatus"); + var shards = redisInfo.Select(x => Proto.Unmarshal(x.Value)).OrderBy(x => x.ShardId); + + var redisClusterInfo = await db.HashGetAllAsync("pluralkit:cluster_stats"); + var clusterInfo = redisClusterInfo.Select(x => JsonConvert.DeserializeObject(x.Value)); + + var guildCount = clusterInfo.Sum(x => x.GuildCount); + var channelCount = clusterInfo.Sum(x => x.ChannelCount); + + var stats = await _repo.GetStats(); + + var o = new JObject(); + o.Add("shards", shards.ToJson()); + o.Add("stats", stats.ToJson(guildCount, channelCount)); + o.Add("version", BuildInfoService.FullVersion); + + return Ok(o); + } + + [HttpPost("bulk_privacy/member")] + public async Task BulkMemberPrivacy([FromBody] JObject inner) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) + throw Errors.GenericAuthError; + + var data = new JObject(); + data.Add("privacy", inner); + + var patch = MemberPatch.FromJSON(data); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + await _db.ExecuteQuery(patch.Apply(new Query("members").Where("system", systemId))); + + return NoContent(); + } + + [HttpPost("bulk_privacy/group")] + public async Task BulkGroupPrivacy([FromBody] JObject inner) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) + throw Errors.GenericAuthError; + + var data = new JObject(); + data.Add("privacy", inner); + + var patch = GroupPatch.FromJson(data); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + await _db.ExecuteQuery(patch.Apply(new Query("groups").Where("system", systemId))); + + return NoContent(); + } + + [HttpPost("discord/callback")] + public async Task DiscordLogin([FromBody] JObject data) + { + if (_config.ClientId == null) return NotFound(); + + using var client = new HttpClient(); + + var res = await client.PostAsync("https://discord.com/api/v10/oauth2/token", new FormUrlEncodedContent( + new Dictionary{ + { "client_id", _config.ClientId }, + { "client_secret", _config.ClientSecret }, + { "grant_type", "authorization_code" }, + { "redirect_uri", data.Value("redirect_domain") + "/login/discord" }, + { "code", data.Value("code") }, + })); + + var h = await res.Content.ReadAsStringAsync(); + var c = JsonConvert.DeserializeObject(h); + + if (c.access_token == null) + return BadRequest(PrivateJsonExt.ObjectWithError(c.error_description)); + + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {c.access_token}"); + + var resp = await client.GetAsync("https://discord.com/api/v10/users/@me"); + var user = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var userId = user.Value("id"); + + var system = await ResolveSystem(userId); + if (system == null) + return BadRequest(PrivateJsonExt.ObjectWithError("User does not have a system registered!")); + + var config = await _repo.GetSystemConfig(system.Id); + + // TODO + + // resp = await client.GetAsync("https://discord.com/api/v10/users/@me/guilds"); + // var guilds = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + // await _redis.Connection.GetDatabase().HashSetAsync( + // $"user_guilds::{userId}", + // guilds.Select(g => new HashEntry(g.Value("id"), true)).ToArray() + // ); + + var o = new JObject(); + + o.Add("system", system.ToJson(LookupContext.ByOwner)); + o.Add("config", config.ToJson()); + o.Add("user", user); + o.Add("token", system.Token); + + return Ok(o); + } +} + +public record OAuth2TokenResponse +{ + public string access_token; + public string? error; + public string? error_description; +} + +public static class PrivateJsonExt +{ + public static JObject ObjectWithError(string error) + { + var o = new JObject(); + o.Add("error", error); + return o; + } + + public static JArray ToJson(this IEnumerable shards) + { + var o = new JArray(); + + foreach (var shard in shards) + { + var s = new JObject(); + s.Add("id", shard.ShardId); + + if (!shard.Up) + s.Add("status", "down"); + else + s.Add("status", "up"); + + s.Add("ping", shard.Latency); + s.Add("disconnection_count", shard.DisconnectionCount); + s.Add("last_heartbeat", shard.LastHeartbeat.ToString()); + s.Add("last_connection", shard.LastConnection.ToString()); + if (shard.HasClusterId) + s.Add("cluster_id", shard.ClusterId); + + o.Add(s); + } + + return o; + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/AccountController.cs b/PluralKit.API/Controllers/v1/AccountController.cs deleted file mode 100644 index edb3114a..00000000 --- a/PluralKit.API/Controllers/v1/AccountController.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Mvc; - -using Newtonsoft.Json.Linq; - -using PluralKit.Core; - -namespace PluralKit.API -{ - [ApiController] - [ApiVersion("1.0")] - [Route( "v{version:apiVersion}/a" )] - public class AccountController: ControllerBase - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - public AccountController(IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - } - - [HttpGet("{aid}")] - public async Task> GetSystemByAccount(ulong aid) - { - var system = await _db.Execute(c => _repo.GetSystemByAccount(c, aid)); - if (system == null) - return NotFound("Account not found."); - - return Ok(system.ToJson(User.ContextFor(system))); - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/JsonModelExt.cs b/PluralKit.API/Controllers/v1/JsonModelExt.cs deleted file mode 100644 index d9e3adc7..00000000 --- a/PluralKit.API/Controllers/v1/JsonModelExt.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Linq; - -using Newtonsoft.Json.Linq; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public static class JsonModelExt - { - public static JObject ToJson(this PKSystem system, LookupContext ctx) - { - var o = new JObject(); - o.Add("id", system.Hid); - o.Add("name", system.Name); - o.Add("description", system.DescriptionFor(ctx)); - o.Add("tag", system.Tag); - o.Add("avatar_url", system.AvatarUrl); - o.Add("created", system.Created.FormatExport()); - o.Add("tz", system.UiTz); - o.Add("description_privacy", ctx == LookupContext.ByOwner ? system.DescriptionPrivacy.ToJsonString() : null); - o.Add("member_list_privacy", ctx == LookupContext.ByOwner ? system.MemberListPrivacy.ToJsonString() : null); - o.Add("front_privacy", ctx == LookupContext.ByOwner ? system.FrontPrivacy.ToJsonString() : null); - o.Add("front_history_privacy", ctx == LookupContext.ByOwner ? system.FrontHistoryPrivacy.ToJsonString() : null); - return o; - } - - public static SystemPatch ToSystemPatch(JObject o) - { - var patch = new SystemPatch(); - if (o.ContainsKey("name")) patch.Name = o.Value("name").NullIfEmpty().BoundsCheckField(Limits.MaxSystemNameLength, "System name"); - if (o.ContainsKey("description")) patch.Description = o.Value("description").NullIfEmpty().BoundsCheckField(Limits.MaxDescriptionLength, "System description"); - if (o.ContainsKey("tag")) patch.Tag = o.Value("tag").NullIfEmpty().BoundsCheckField(Limits.MaxSystemTagLength, "System tag"); - if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value("avatar_url").NullIfEmpty().BoundsCheckField(Limits.MaxUriLength, "System avatar URL"); - if (o.ContainsKey("tz")) patch.UiTz = o.Value("tz") ?? "UTC"; - - if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = o.Value("description_privacy").ParsePrivacy("description"); - if (o.ContainsKey("member_list_privacy")) patch.MemberListPrivacy = o.Value("member_list_privacy").ParsePrivacy("member list"); - if (o.ContainsKey("front_privacy")) patch.FrontPrivacy = o.Value("front_privacy").ParsePrivacy("front"); - if (o.ContainsKey("front_history_privacy")) patch.FrontHistoryPrivacy = o.Value("front_history_privacy").ParsePrivacy("front history"); - return patch; - } - - public static JObject ToJson(this PKMember member, LookupContext ctx) - { - var includePrivacy = ctx == LookupContext.ByOwner; - - var o = new JObject(); - o.Add("id", member.Hid); - o.Add("name", member.NameFor(ctx)); - // o.Add("color", member.ColorPrivacy.CanAccess(ctx) ? member.Color : null); - o.Add("color", member.Color); - o.Add("display_name", member.NamePrivacy.CanAccess(ctx) ? member.DisplayName : null); - o.Add("birthday", member.BirthdayFor(ctx)?.FormatExport()); - o.Add("pronouns", member.PronounsFor(ctx)); - o.Add("avatar_url", member.AvatarFor(ctx)); - o.Add("description", member.DescriptionFor(ctx)); - - var tagArray = new JArray(); - foreach (var tag in member.ProxyTags) - tagArray.Add(new JObject {{"prefix", tag.Prefix}, {"suffix", tag.Suffix}}); - o.Add("proxy_tags", tagArray); - - o.Add("keep_proxy", member.KeepProxy); - - o.Add("privacy", includePrivacy ? (member.MemberVisibility.LevelName()) : null); - - o.Add("visibility", includePrivacy ? (member.MemberVisibility.LevelName()) : null); - o.Add("name_privacy", includePrivacy ? (member.NamePrivacy.LevelName()) : null); - o.Add("description_privacy", includePrivacy ? (member.DescriptionPrivacy.LevelName()) : null); - o.Add("birthday_privacy", includePrivacy ? (member.BirthdayPrivacy.LevelName()) : null); - o.Add("pronoun_privacy", includePrivacy ? (member.PronounPrivacy.LevelName()) : null); - o.Add("avatar_privacy", includePrivacy ? (member.AvatarPrivacy.LevelName()) : null); - // o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy.LevelName()) : null); - o.Add("metadata_privacy", includePrivacy ? (member.MetadataPrivacy.LevelName()) : null); - - o.Add("created", member.CreatedFor(ctx)?.FormatExport()); - - if (member.ProxyTags.Count > 0) - { - // Legacy compatibility only, TODO: remove at some point - o.Add("prefix", member.ProxyTags?.FirstOrDefault().Prefix); - o.Add("suffix", member.ProxyTags?.FirstOrDefault().Suffix); - } - - return o; - } - - public static MemberPatch ToMemberPatch(JObject o) - { - var patch = new MemberPatch(); - - if (o.ContainsKey("name") && o["name"].Type == JTokenType.Null) - throw new JsonModelParseError("Member name can not be set to null."); - - if (o.ContainsKey("name")) patch.Name = o.Value("name").BoundsCheckField(Limits.MaxMemberNameLength, "Member name"); - if (o.ContainsKey("color")) patch.Color = o.Value("color").NullIfEmpty()?.ToLower(); - if (o.ContainsKey("display_name")) patch.DisplayName = o.Value("display_name").NullIfEmpty().BoundsCheckField(Limits.MaxMemberNameLength, "Member display name"); - if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value("avatar_url").NullIfEmpty().BoundsCheckField(Limits.MaxUriLength, "Member avatar URL"); - if (o.ContainsKey("birthday")) - { - var str = o.Value("birthday").NullIfEmpty(); - var res = DateTimeFormats.DateExportFormat.Parse(str); - if (res.Success) patch.Birthday = res.Value; - else if (str == null) patch.Birthday = null; - else throw new JsonModelParseError("Could not parse member birthday."); - } - - if (o.ContainsKey("pronouns")) patch.Pronouns = o.Value("pronouns").NullIfEmpty().BoundsCheckField(Limits.MaxPronounsLength, "Member pronouns"); - if (o.ContainsKey("description")) patch.Description = o.Value("description").NullIfEmpty().BoundsCheckField(Limits.MaxDescriptionLength, "Member descriptoin"); - if (o.ContainsKey("keep_proxy")) patch.KeepProxy = o.Value("keep_proxy"); - - if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags")) - patch.ProxyTags = new[] {new ProxyTag(o.Value("prefix"), o.Value("suffix"))}; - else if (o.ContainsKey("proxy_tags")) - { - patch.ProxyTags = o.Value("proxy_tags") - .OfType().Select(o => new ProxyTag(o.Value("prefix"), o.Value("suffix"))) - .ToArray(); - } - if(o.ContainsKey("privacy")) //TODO: Deprecate this completely in api v2 - { - var plevel = o.Value("privacy").ParsePrivacy("member"); - - patch.Visibility = plevel; - patch.NamePrivacy = plevel; - patch.AvatarPrivacy = plevel; - patch.DescriptionPrivacy = plevel; - patch.BirthdayPrivacy = plevel; - patch.PronounPrivacy = plevel; - // member.ColorPrivacy = plevel; - patch.MetadataPrivacy = plevel; - } - else - { - if (o.ContainsKey("visibility")) patch.Visibility = o.Value("visibility").ParsePrivacy("member"); - if (o.ContainsKey("name_privacy")) patch.NamePrivacy = o.Value("name_privacy").ParsePrivacy("member"); - if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = o.Value("description_privacy").ParsePrivacy("member"); - if (o.ContainsKey("avatar_privacy")) patch.AvatarPrivacy = o.Value("avatar_privacy").ParsePrivacy("member"); - if (o.ContainsKey("birthday_privacy")) patch.BirthdayPrivacy = o.Value("birthday_privacy").ParsePrivacy("member"); - if (o.ContainsKey("pronoun_privacy")) patch.PronounPrivacy = o.Value("pronoun_privacy").ParsePrivacy("member"); - // if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.Value("color_privacy").ParsePrivacy("member"); - if (o.ContainsKey("metadata_privacy")) patch.MetadataPrivacy = o.Value("metadata_privacy").ParsePrivacy("member"); - } - - return patch; - } - - private static string BoundsCheckField(this string input, int maxLength, string nameInError) - { - if (input != null && input.Length > maxLength) - throw new JsonModelParseError($"{nameInError} too long ({input.Length} > {maxLength})."); - return input; - } - - private static string ToJsonString(this PrivacyLevel level) => level.LevelName(); - - private static PrivacyLevel ParsePrivacy(this string input, string errorName) - { - if (input == null) return PrivacyLevel.Private; - if (input == "") return PrivacyLevel.Private; - if (input == "private") return PrivacyLevel.Private; - if (input == "public") return PrivacyLevel.Public; - throw new JsonModelParseError($"Could not parse {errorName} privacy."); - } - } - - public class JsonModelParseError: Exception - { - public JsonModelParseError(string message): base(message) { } - } -} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/MemberController.cs b/PluralKit.API/Controllers/v1/MemberController.cs deleted file mode 100644 index 539893c2..00000000 --- a/PluralKit.API/Controllers/v1/MemberController.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Threading.Tasks; - -using Dapper; - -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -using Newtonsoft.Json.Linq; - -using PluralKit.Core; - -namespace PluralKit.API -{ - [ApiController] - [ApiVersion("1.0")] - [Route( "v{version:apiVersion}/m" )] - public class MemberController: ControllerBase - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly IAuthorizationService _auth; - - public MemberController(IAuthorizationService auth, IDatabase db, ModelRepository repo) - { - _auth = auth; - _db = db; - _repo = repo; - } - - [HttpGet("{hid}")] - public async Task> GetMember(string hid) - { - var member = await _db.Execute(conn => _repo.GetMemberByHid(conn, hid)); - if (member == null) return NotFound("Member not found."); - - return Ok(member.ToJson(User.ContextFor(member))); - } - - [HttpPost] - [Authorize] - public async Task> PostMember([FromBody] JObject properties) - { - if (!properties.ContainsKey("name")) - return BadRequest("Member name must be specified."); - - var systemId = User.CurrentSystem(); - - await using var conn = await _db.Obtain(); - var systemData = await _repo.GetSystem(conn, systemId); - - // Enforce per-system member limit - var memberCount = await conn.QuerySingleAsync("select count(*) from members where system = @System", new {System = systemId}); - var memberLimit = systemData?.MemberLimitOverride ?? Limits.MaxMemberCount; - if (memberCount >= memberLimit) - return BadRequest($"Member limit reached ({memberCount} / {memberLimit})."); - - var member = await _repo.CreateMember(conn, systemId, properties.Value("name")); - MemberPatch patch; - try - { - patch = JsonModelExt.ToMemberPatch(properties); - } - catch (JsonModelParseError e) - { - return BadRequest(e.Message); - } - - member = await _repo.UpdateMember(conn, member.Id, patch); - return Ok(member.ToJson(User.ContextFor(member))); - } - - [HttpPatch("{hid}")] - [Authorize] - public async Task> PatchMember(string hid, [FromBody] JObject changes) - { - await using var conn = await _db.Obtain(); - - var member = await _repo.GetMemberByHid(conn, hid); - if (member == null) return NotFound("Member not found."); - - var res = await _auth.AuthorizeAsync(User, member, "EditMember"); - if (!res.Succeeded) return Unauthorized($"Member '{hid}' is not part of your system."); - - MemberPatch patch; - try - { - patch = JsonModelExt.ToMemberPatch(changes); - } - catch (JsonModelParseError e) - { - return BadRequest(e.Message); - } - - var newMember = await _repo.UpdateMember(conn, member.Id, patch); - return Ok(newMember.ToJson(User.ContextFor(newMember))); - } - - [HttpDelete("{hid}")] - [Authorize] - public async Task DeleteMember(string hid) - { - await using var conn = await _db.Obtain(); - - var member = await _repo.GetMemberByHid(conn, hid); - if (member == null) return NotFound("Member not found."); - - var res = await _auth.AuthorizeAsync(User, member, "EditMember"); - if (!res.Succeeded) return Unauthorized($"Member '{hid}' is not part of your system."); - - await _repo.DeleteMember(conn, member.Id); - return Ok(); - } - } -} diff --git a/PluralKit.API/Controllers/v1/MessageController.cs b/PluralKit.API/Controllers/v1/MessageController.cs deleted file mode 100644 index a036a4c0..00000000 --- a/PluralKit.API/Controllers/v1/MessageController.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Mvc; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -using NodaTime; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public struct MessageReturn - { - [JsonProperty("timestamp")] public Instant Timestamp; - [JsonProperty("id")] public string Id; - [JsonProperty("original")] public string Original; - [JsonProperty("sender")] public string Sender; - [JsonProperty("channel")] public string Channel; - - [JsonProperty("system")] public JObject System; - [JsonProperty("member")] public JObject Member; - } - - [ApiController] - [ApiVersion("1.0")] - [Route( "v{version:apiVersion}/msg" )] - public class MessageController: ControllerBase - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - - public MessageController(ModelRepository repo, IDatabase db) - { - _repo = repo; - _db = db; - } - - [HttpGet("{mid}")] - public async Task> GetMessage(ulong mid) - { - var msg = await _db.Execute(c => _repo.GetMessage(c, mid)); - if (msg == null) return NotFound("Message not found."); - - return new MessageReturn - { - Timestamp = Instant.FromUnixTimeMilliseconds((long) (msg.Message.Mid >> 22) + 1420070400000), - Id = msg.Message.Mid.ToString(), - Channel = msg.Message.Channel.ToString(), - Sender = msg.Message.Sender.ToString(), - Member = msg.Member.ToJson(User.ContextFor(msg.System)), - System = msg.System.ToJson(User.ContextFor(msg.System)), - Original = msg.Message.OriginalMid?.ToString() - }; - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/SystemController.cs b/PluralKit.API/Controllers/v1/SystemController.cs deleted file mode 100644 index 0dce14e1..00000000 --- a/PluralKit.API/Controllers/v1/SystemController.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using Dapper; - -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -using NodaTime; - -using PluralKit.Core; - -namespace PluralKit.API -{ - public struct SwitchesReturn - { - [JsonProperty("timestamp")] public Instant Timestamp { get; set; } - [JsonProperty("members")] public IEnumerable Members { get; set; } - } - - public struct FrontersReturn - { - [JsonProperty("timestamp")] public Instant Timestamp { get; set; } - [JsonProperty("members")] public IEnumerable Members { get; set; } - } - - public struct PostSwitchParams - { - public ICollection Members { get; set; } - } - - [ApiController] - [ApiVersion("1.0")] - [Route( "v{version:apiVersion}/s" )] - public class SystemController : ControllerBase - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly IAuthorizationService _auth; - - public SystemController(IDatabase db, IAuthorizationService auth, ModelRepository repo) - { - _db = db; - _auth = auth; - _repo = repo; - } - - [HttpGet] - [Authorize] - public async Task> GetOwnSystem() - { - var system = await _db.Execute(c => _repo.GetSystem(c, User.CurrentSystem())); - return system.ToJson(User.ContextFor(system)); - } - - [HttpGet("{hid}")] - public async Task> GetSystem(string hid) - { - var system = await _db.Execute(c => _repo.GetSystemByHid(c, hid)); - if (system == null) return NotFound("System not found."); - return Ok(system.ToJson(User.ContextFor(system))); - } - - [HttpGet("{hid}/members")] - public async Task>> GetMembers(string hid) - { - var system = await _db.Execute(c => _repo.GetSystemByHid(c, hid)); - if (system == null) - return NotFound("System not found."); - - if (!system.MemberListPrivacy.CanAccess(User.ContextFor(system))) - return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized to view member list."); - - var members = _db.Execute(c => _repo.GetSystemMembers(c, system.Id)); - return Ok(await members - .Where(m => m.MemberVisibility.CanAccess(User.ContextFor(system))) - .Select(m => m.ToJson(User.ContextFor(system))) - .ToListAsync()); - } - - [HttpGet("{hid}/switches")] - public async Task>> GetSwitches(string hid, [FromQuery(Name = "before")] Instant? before) - { - if (before == null) before = SystemClock.Instance.GetCurrentInstant(); - - await using var conn = await _db.Obtain(); - - var system = await _repo.GetSystemByHid(conn, hid); - if (system == null) return NotFound("System not found."); - - var auth = await _auth.AuthorizeAsync(User, system, "ViewFrontHistory"); - if (!auth.Succeeded) return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized to view front history."); - - var res = await conn.QueryAsync( - @"select *, array( - select members.hid from switch_members, members - where switch_members.switch = switches.id and members.id = switch_members.member - ) as members from switches - where switches.system = @System and switches.timestamp < @Before - order by switches.timestamp desc - limit 100;", new {System = system.Id, Before = before}); - return Ok(res); - } - - [HttpGet("{hid}/fronters")] - public async Task> GetFronters(string hid) - { - await using var conn = await _db.Obtain(); - - var system = await _repo.GetSystemByHid(conn, hid); - if (system == null) return NotFound("System not found."); - - var auth = await _auth.AuthorizeAsync(User, system, "ViewFront"); - if (!auth.Succeeded) return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized to view fronter."); - - var sw = await _repo.GetLatestSwitch(conn, system.Id); - if (sw == null) return NotFound("System has no registered switches."); - - var members = _repo.GetSwitchMembers(conn, sw.Id); - return Ok(new FrontersReturn - { - Timestamp = sw.Timestamp, - Members = await members.Select(m => m.ToJson(User.ContextFor(system))).ToListAsync() - }); - } - - [HttpPatch] - [Authorize] - public async Task> EditSystem([FromBody] JObject changes) - { - await using var conn = await _db.Obtain(); - var system = await _repo.GetSystem(conn, User.CurrentSystem()); - - SystemPatch patch; - try - { - patch = JsonModelExt.ToSystemPatch(changes); - } - catch (JsonModelParseError e) - { - return BadRequest(e.Message); - } - - await _repo.UpdateSystem(conn, system!.Id, patch); - return Ok(system.ToJson(User.ContextFor(system))); - } - - [HttpPost("switches")] - [Authorize] - public async Task PostSwitch([FromBody] PostSwitchParams param) - { - if (param.Members.Distinct().Count() != param.Members.Count) - return BadRequest("Duplicate members in member list."); - - await using var conn = await _db.Obtain(); - - // We get the current switch, if it exists - var latestSwitch = await _repo.GetLatestSwitch(conn, User.CurrentSystem()); - if (latestSwitch != null) - { - var latestSwitchMembers = _repo.GetSwitchMembers(conn, latestSwitch.Id); - - // Bail if this switch is identical to the latest one - if (await latestSwitchMembers.Select(m => m.Hid).SequenceEqualAsync(param.Members.ToAsyncEnumerable())) - return BadRequest("New members identical to existing fronters."); - } - - // Resolve member objects for all given IDs - var membersList = (await conn.QueryAsync("select * from members where hid = any(@Hids)", new {Hids = param.Members})).ToList(); - - foreach (var member in membersList) - if (member.System != User.CurrentSystem()) - return BadRequest($"Cannot switch to member '{member.Hid}' not in system."); - - // membersList is in DB order, and we want it in actual input order - // so we go through a dict and map the original input appropriately - var membersDict = membersList.ToDictionary(m => m.Hid); - - var membersInOrder = new List(); - // We do this without .Select() since we want to have the early return bail if it doesn't find the member - foreach (var givenMemberId in param.Members) - { - if (!membersDict.TryGetValue(givenMemberId, out var member)) - return BadRequest($"Member '{givenMemberId}' not found."); - membersInOrder.Add(member); - } - - // Finally, log the switch (yay!) - await _repo.AddSwitch(conn, User.CurrentSystem(), membersInOrder.Select(m => m.Id).ToList()); - return NoContent(); - } - } -} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/DiscordControllerV2.cs b/PluralKit.API/Controllers/v2/DiscordControllerV2.cs new file mode 100644 index 00000000..477ad8ca --- /dev/null +++ b/PluralKit.API/Controllers/v2/DiscordControllerV2.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Mvc; + +using Newtonsoft.Json.Linq; + +using PluralKit.Core; + +namespace PluralKit.API; + +[ApiController] +[Route("v2")] +public class DiscordControllerV2: PKControllerBase +{ + public DiscordControllerV2(IServiceProvider svc) : base(svc) { } + + + [HttpGet("systems/{systemRef}/guilds/{guild_id}")] + public async Task SystemGuildGet(string systemRef, ulong guild_id) + { + var system = await ResolveSystem(systemRef); + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + + var settings = await _repo.GetSystemGuild(guild_id, system.Id, false); + if (settings == null) + throw Errors.SystemGuildNotFound; + + return Ok(settings.ToJson()); + } + + [HttpPatch("systems/{systemRef}/guilds/{guild_id}")] + public async Task DoSystemGuildPatch(string systemRef, ulong guild_id, [FromBody] JObject data) + { + var system = await ResolveSystem(systemRef); + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + + var settings = await _repo.GetSystemGuild(guild_id, system.Id, false); + if (settings == null) + throw Errors.SystemGuildNotFound; + + var patch = SystemGuildPatch.FromJson(data); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + var newSettings = await _repo.UpdateSystemGuild(system.Id, guild_id, patch); + return Ok(newSettings.ToJson()); + } + + [HttpGet("members/{memberRef}/guilds/{guild_id}")] + public async Task MemberGuildGet(string memberRef, ulong guild_id) + { + var system = await ResolveSystem("@me"); + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + if (member.System != system.Id) + throw Errors.NotOwnMemberError; + + var settings = await _repo.GetMemberGuild(guild_id, member.Id, false); + if (settings == null) + throw Errors.MemberGuildNotFound; + + return Ok(settings.ToJson()); + } + + [HttpPatch("members/{memberRef}/guilds/{guild_id}")] + public async Task DoMemberGuildPatch(string memberRef, ulong guild_id, [FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + if (member.System != system.Id) + throw Errors.NotOwnMemberError; + + var settings = await _repo.GetMemberGuild(guild_id, member.Id, false); + if (settings == null) + throw Errors.MemberGuildNotFound; + + var patch = MemberGuildPatch.FromJson(data); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + var newSettings = await _repo.UpdateMemberGuild(member.Id, guild_id, patch); + return Ok(newSettings.ToJson()); + } + + [HttpGet("messages/{messageId}")] + public async Task> MessageGet(ulong messageId) + { + var msg = await _db.Execute(c => _repo.GetMessage(c, messageId)); + if (msg == null) + throw Errors.MessageNotFound; + + var ctx = msg.System == null ? LookupContext.ByNonOwner : ContextFor(msg.System); + return msg.ToJson(ctx); + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs new file mode 100644 index 00000000..01eee1e2 --- /dev/null +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Mvc; + +using Newtonsoft.Json.Linq; + +using PluralKit.Core; + +namespace PluralKit.API; + +[ApiController] +[Route("v2")] +public class GroupControllerV2: PKControllerBase +{ + public GroupControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("systems/{systemRef}/groups")] + public async Task GetSystemGroups(string systemRef, [FromQuery] bool with_members) + { + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + + var ctx = ContextFor(system); + + if (with_members && !system.MemberListPrivacy.CanAccess(ctx)) + throw Errors.UnauthorizedMemberList; + + if (!system.GroupListPrivacy.CanAccess(ContextFor(system))) + throw Errors.UnauthorizedGroupList; + + var groups = _repo.GetSystemGroups(system.Id); + + var j_groups = await groups + .Where(g => g.Visibility.CanAccess(ctx)) + .Select(g => g.ToJson(ctx, needsMembersArray: with_members)) + .ToListAsync(); + + if (with_members && !system.MemberListPrivacy.CanAccess(ctx)) + throw Errors.UnauthorizedMemberList; + + if (with_members && j_groups.Count > 0) + { + var q = await _repo.GetGroupMemberInfo(await groups.Select(x => x.Id).ToListAsync()); + + foreach (var row in q) + if (row.MemberVisibility.CanAccess(ctx)) + ((JArray)j_groups.Find(x => x.Value("id") == row.Group)["members"]).Add(row.MemberUuid); + } + + return Ok(j_groups); + } + + [HttpPost("groups")] + public async Task GroupCreate([FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + var config = await _repo.GetSystemConfig(system.Id); + + // Check group cap + var existingGroupCount = await _repo.GetSystemGroupCount(system.Id); + var groupLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount; + if (existingGroupCount >= groupLimit) + throw Errors.GroupLimitReached; + + var patch = GroupPatch.FromJson(data); + patch.AssertIsValid(); + if (!patch.Name.IsPresent) + patch.Errors.Add(new ValidationError("name", "Key 'name' is required when creating new group.")); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + using var conn = await _db.Obtain(); + using var tx = await conn.BeginTransactionAsync(); + + var newGroup = await _repo.CreateGroup(system.Id, patch.Name.Value, conn); + newGroup = await _repo.UpdateGroup(newGroup.Id, patch, conn); + + _ = _dispatch.Dispatch(newGroup.Id, new UpdateDispatchData() + { + Event = DispatchEvent.CREATE_GROUP, + EventData = patch.ToJson(), + }); + + await tx.CommitAsync(); + + return Ok(newGroup.ToJson(LookupContext.ByOwner)); + } + + [HttpGet("groups/{groupRef}")] + public async Task GroupGet(string groupRef) + { + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + + var system = await _repo.GetSystem(group.System); + + return Ok(group.ToJson(ContextFor(group), system.Hid)); + } + + [HttpPatch("groups/{groupRef}")] + public async Task DoGroupPatch(string groupRef, [FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + if (group.System != system.Id) + throw Errors.NotOwnGroupError; + + var patch = GroupPatch.FromJson(data); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + var newGroup = await _repo.UpdateGroup(group.Id, patch); + return Ok(newGroup.ToJson(LookupContext.ByOwner)); + } + + [HttpDelete("groups/{groupRef}")] + public async Task GroupDelete(string groupRef) + { + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + + var system = await ResolveSystem("@me"); + if (system.Id != group.System) + throw Errors.NotOwnGroupError; + + await _repo.DeleteGroup(group.Id); + + return NoContent(); + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs new file mode 100644 index 00000000..7a40f244 --- /dev/null +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -0,0 +1,271 @@ +using Microsoft.AspNetCore.Mvc; + +using Newtonsoft.Json.Linq; + +using PluralKit.Core; + +namespace PluralKit.API; + +[ApiController] +[Route("v2")] +public class GroupMemberControllerV2: PKControllerBase +{ + public GroupMemberControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("groups/{groupRef}/members")] + public async Task GetGroupMembers(string groupRef) + { + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + + var ctx = ContextFor(group); + + if (!group.ListPrivacy.CanAccess(ctx)) + throw Errors.UnauthorizedGroupMemberList; + + var members = _repo.GetGroupMembers(group.Id).Where(m => m.MemberVisibility.CanAccess(ctx)); + + var o = new JArray(); + + await foreach (var member in members) + o.Add(member.ToJson(ctx)); + + return Ok(o); + } + + [HttpPost("groups/{groupRef}/members/add")] + public async Task AddGroupMembers(string groupRef, [FromBody] JArray memberRefs) + { + if (memberRefs.Count == 0) + throw Errors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + if (group.System != system.Id) + throw Errors.NotOwnGroupError; + + var members = new List(); + + foreach (var JmemberRef in memberRefs) + { + var memberRef = JmemberRef.Value(); + var member = await ResolveMember(memberRef, cache: true); + + // todo: have a list of these errors instead of immediately throwing + + if (member == null) + throw Errors.MemberNotFoundWithRef(memberRef); + if (member.System != system.Id) + throw Errors.NotOwnMemberErrorWithRef(memberRef); + + members.Add(member.Id); + } + + var existingMembers = await _repo.GetGroupMembers(group.Id).Select(x => x.Id).ToListAsync(); + members = members.Where(x => !existingMembers.Contains(x)).ToList(); + + if (members.Count > 0) + await _repo.AddMembersToGroup(group.Id, members); + + return NoContent(); + } + + [HttpPost("groups/{groupRef}/members/remove")] + public async Task RemoveGroupMembers(string groupRef, [FromBody] JArray memberRefs) + { + if (memberRefs.Count == 0) + throw Errors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + if (group.System != system.Id) + throw Errors.NotOwnGroupError; + + var members = new List(); + + foreach (var JmemberRef in memberRefs) + { + var memberRef = JmemberRef.Value(); + var member = await ResolveMember(memberRef, cache: true); + + if (member == null) + throw Errors.MemberNotFoundWithRef(memberRef); + if (member.System != system.Id) + throw Errors.NotOwnMemberErrorWithRef(memberRef); + + members.Add(member.Id); + } + + await _repo.RemoveMembersFromGroup(group.Id, members); + + return NoContent(); + } + + [HttpPost("groups/{groupRef}/members/overwrite")] + public async Task OverwriteGroupMembers(string groupRef, [FromBody] JArray memberRefs) + { + var system = await ResolveSystem("@me"); + + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + if (group.System != system.Id) + throw Errors.NotOwnGroupError; + + var members = new List(); + + foreach (var JmemberRef in memberRefs) + { + var memberRef = JmemberRef.Value(); + var member = await ResolveMember(memberRef, cache: true); + + if (member == null) + throw Errors.MemberNotFoundWithRef(memberRef); + if (member.System != system.Id) + throw Errors.NotOwnMemberErrorWithRef(memberRef); + + members.Add(member.Id); + } + + await _repo.ClearGroupMembers(group.Id); + + if (members.Count > 0) + await _repo.AddMembersToGroup(group.Id, members); + + return NoContent(); + } + + + [HttpGet("members/{memberRef}/groups")] + public async Task GetMemberGroups(string memberRef) + { + var member = await ResolveMember(memberRef); + var ctx = ContextFor(member); + + var system = await _repo.GetSystem(member.System); + if (!system.GroupListPrivacy.CanAccess(ctx)) + throw Errors.UnauthorizedGroupList; + + var groups = _repo.GetMemberGroups(member.Id).Where(g => g.Visibility.CanAccess(ctx)); + + var o = new JArray(); + + await foreach (var group in groups) + o.Add(group.ToJson(ctx)); + + return Ok(o); + } + + [HttpPost("members/{memberRef}/groups/add")] + public async Task AddMemberGroups(string memberRef, [FromBody] JArray groupRefs) + { + if (groupRefs.Count == 0) + throw Errors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + if (member.System != system.Id) + throw Errors.NotOwnMemberError; + + var groups = new List(); + + foreach (var JgroupRef in groupRefs) + { + var groupRef = JgroupRef.Value(); + var group = await ResolveGroup(groupRef, cache: true); + + if (group == null) + throw Errors.GroupNotFound; + if (group.System != system.Id) + throw Errors.NotOwnGroupErrorWithRef(groupRef); + + groups.Add(group.Id); + } + + var existingGroups = await _repo.GetMemberGroups(member.Id).Select(x => x.Id).ToListAsync(); + groups = groups.Where(x => !existingGroups.Contains(x)).ToList(); + + if (groups.Count > 0) + await _repo.AddGroupsToMember(member.Id, groups); + + return NoContent(); + } + + [HttpPost("members/{memberRef}/groups/remove")] + public async Task RemoveMemberGroups(string memberRef, [FromBody] JArray groupRefs) + { + if (groupRefs.Count == 0) + throw Errors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + if (member.System != system.Id) + throw Errors.NotOwnMemberError; + + var groups = new List(); + + foreach (var JgroupRef in groupRefs) + { + var groupRef = JgroupRef.Value(); + var group = await ResolveGroup(groupRef, cache: true); + + if (group == null) + throw Errors.GroupNotFoundWithRef(groupRef); + if (group.System != system.Id) + throw Errors.NotOwnGroupErrorWithRef(groupRef); + + groups.Add(group.Id); + } + + await _repo.RemoveGroupsFromMember(member.Id, groups); + + return NoContent(); + } + + [HttpPost("members/{memberRef}/groups/overwrite")] + public async Task OverwriteMemberGroups(string memberRef, [FromBody] JArray groupRefs) + { + var system = await ResolveSystem("@me"); + + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + if (member.System != system.Id) + throw Errors.NotOwnMemberError; + + var groups = new List(); + + foreach (var JgroupRef in groupRefs) + { + var groupRef = JgroupRef.Value(); + var group = await ResolveGroup(groupRef, cache: true); + + if (group == null) + throw Errors.GroupNotFoundWithRef(groupRef); + if (group.System != system.Id) + throw Errors.NotOwnGroupErrorWithRef(groupRef); + + groups.Add(group.Id); + } + + await _repo.ClearMemberGroups(member.Id); + + if (groups.Count > 0) + await _repo.AddGroupsToMember(member.Id, groups); + + return NoContent(); + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs new file mode 100644 index 00000000..dfa1eb87 --- /dev/null +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Mvc; + +using Newtonsoft.Json.Linq; + +using PluralKit.Core; + +namespace PluralKit.API; + +[ApiController] +[Route("v2")] +public class MemberControllerV2: PKControllerBase +{ + public MemberControllerV2(IServiceProvider svc) : base(svc) { } + + + [HttpGet("systems/{systemRef}/members")] + public async Task GetSystemMembers(string systemRef) + { + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + + var ctx = ContextFor(system); + + if (!system.MemberListPrivacy.CanAccess(ContextFor(system))) + throw Errors.UnauthorizedMemberList; + + var members = _repo.GetSystemMembers(system.Id); + return Ok(await members + .Where(m => m.MemberVisibility.CanAccess(ctx)) + .Select(m => m.ToJson(ctx)) + .ToListAsync()); + } + + [HttpPost("members")] + public async Task MemberCreate([FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + var config = await _repo.GetSystemConfig(system.Id); + + var memberCount = await _repo.GetSystemMemberCount(system.Id); + var memberLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount; + if (memberCount >= memberLimit) + throw Errors.MemberLimitReached; + + var patch = MemberPatch.FromJSON(data); + patch.AssertIsValid(); + if (!patch.Name.IsPresent) + patch.Errors.Add(new ValidationError("name", "Key 'name' is required when creating new member.")); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + using var conn = await _db.Obtain(); + using var tx = await conn.BeginTransactionAsync(); + + var newMember = await _repo.CreateMember(system.Id, patch.Name.Value, conn); + newMember = await _repo.UpdateMember(newMember.Id, patch, conn); + + _ = _dispatch.Dispatch(newMember.Id, new() + { + Event = DispatchEvent.CREATE_MEMBER, + EventData = patch.ToJson(), + }); + + await tx.CommitAsync(); + + return Ok(newMember.ToJson(LookupContext.ByOwner)); + } + + [HttpGet("members/{memberRef}")] + public async Task MemberGet(string memberRef) + { + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + + var system = await _repo.GetSystem(member.System); + + return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid)); + } + + [HttpPatch("members/{memberRef}")] + public async Task DoMemberPatch(string memberRef, [FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + if (member.System != system.Id) + throw Errors.NotOwnMemberError; + + var patch = MemberPatch.FromJSON(data); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + var newMember = await _repo.UpdateMember(member.Id, patch); + return Ok(newMember.ToJson(LookupContext.ByOwner)); + } + + [HttpDelete("members/{memberRef}")] + public async Task MemberDelete(string memberRef) + { + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + + var system = await ResolveSystem("@me"); + if (system.Id != member.System) + throw Errors.NotOwnMemberError; + + await _repo.DeleteMember(member.Id); + + return NoContent(); + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs new file mode 100644 index 00000000..2e580d99 --- /dev/null +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -0,0 +1,270 @@ +using Dapper; + +using Microsoft.AspNetCore.Mvc; + +using Newtonsoft.Json.Linq; + +using NodaTime; + +using PluralKit.Core; + +namespace PluralKit.API; + +[ApiController] +[Route("v2")] +public class SwitchControllerV2: PKControllerBase +{ + public SwitchControllerV2(IServiceProvider svc) : base(svc) { } + + + [HttpGet("systems/{systemRef}/switches")] + public async Task GetSystemSwitches(string systemRef, + [FromQuery(Name = "before")] Instant? before, + [FromQuery(Name = "limit")] int? limit) + { + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + + var ctx = ContextFor(system); + + if (!system.FrontHistoryPrivacy.CanAccess(ctx)) + throw Errors.UnauthorizedFrontHistory; + + if (before == null) + before = SystemClock.Instance.GetCurrentInstant(); + + if (limit == null || limit > 100) + limit = 100; + + var res = await _db.Execute(conn => conn.QueryAsync( + @"select *, array( + select members.hid from switch_members, members + where switch_members.switch = switches.id and members.id = switch_members.member + ) as members from switches + where switches.system = @System and switches.timestamp < @Before + order by switches.timestamp desc + limit @Limit;", + new { System = system.Id, Before = before, Limit = limit } + )); + return Ok(res); + } + + [HttpGet("systems/{systemRef}/fronters")] + public async Task GetSystemFronters(string systemRef) + { + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + + var ctx = ContextFor(system); + + if (!system.FrontPrivacy.CanAccess(ctx)) + throw Errors.UnauthorizedCurrentFronters; + + var sw = await _repo.GetLatestSwitch(system.Id); + if (sw == null) + return NoContent(); + + var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); + return Ok(new FrontersReturnNew + { + Timestamp = sw.Timestamp, + Members = await members.Select(m => m.ToJson(ctx)).ToListAsync(), + Uuid = sw.Uuid, + }); + } + + + [HttpPost("systems/{systemRef}/switches")] + public async Task SwitchCreate(string systemRef, [FromBody] PostSwitchParams data) + { + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + + if (data.Members.Distinct().Count() != data.Members.Count) + throw Errors.DuplicateMembersInList; + + if (data.Timestamp != null && await _repo.GetSwitches(system.Id).Select(x => x.Timestamp) + .ContainsAsync(data.Timestamp.Value)) + throw Errors.SameSwitchTimestampError; + + var members = new List(); + + foreach (var memberRef in data.Members) + { + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFoundWithRef(memberRef); + if (member.System != system.Id) + throw Errors.NotOwnMemberErrorWithRef(memberRef); + members.Add(member); + } + + // We get the current switch, if it exists + var latestSwitch = await _repo.GetLatestSwitch(system.Id); + if (latestSwitch != null && (data.Timestamp == null || data.Timestamp > latestSwitch.Timestamp)) + { + var latestSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, latestSwitch.Id)); + + // Bail if this switch is identical to the latest one + if (await latestSwitchMembers.Select(m => m.Hid) + .SequenceEqualAsync(members.Select(m => m.Hid).ToAsyncEnumerable())) + throw Errors.SameSwitchMembersError; + } + + var newSwitch = + await _db.Execute(conn => _repo.AddSwitch(conn, system.Id, members.Select(m => m.Id).ToList())); + if (data.Timestamp != null) + await _repo.MoveSwitch(newSwitch.Id, data.Timestamp.Value); + + return Ok(new FrontersReturnNew + { + Uuid = newSwitch.Uuid, + Timestamp = data.Timestamp != null ? data.Timestamp.Value : newSwitch.Timestamp, + Members = members.Select(x => x.ToJson(LookupContext.ByOwner)), + }); + } + + + [HttpGet("systems/{systemRef}/switches/{switchRef}")] + public async Task SwitchGet(string systemRef, string switchRef) + { + if (!Guid.TryParse(switchRef, out var switchId)) + throw Errors.InvalidSwitchId; + + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null || system.Id != sw.System) + throw Errors.SwitchNotFoundPublic; + + var ctx = ContextFor(system); + + if (!system.FrontHistoryPrivacy.CanAccess(ctx)) + throw Errors.SwitchNotFoundPublic; + + var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); + return Ok(new FrontersReturnNew + { + Uuid = sw.Uuid, + Timestamp = sw.Timestamp, + Members = await members.Select(m => m.ToJson(ctx)).ToListAsync() + }); + } + + [HttpPatch("systems/{systemRef}/switches/{switchRef}")] + public async Task SwitchPatch(string systemRef, string switchRef, [FromBody] JObject data) + { + // for now, don't need to make a PatchObject for this, since it's only one param + + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + + if (!Guid.TryParse(switchRef, out var switchId)) + throw Errors.InvalidSwitchId; + + var valueStr = data.Value("timestamp").NullIfEmpty(); + if (valueStr == null) + throw new ModelParseError(new List { new("timestamp", "Key 'timestamp' is required.") }); + + var value = Instant.FromDateTimeOffset(DateTime.Parse(valueStr).ToUniversalTime()); + + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null || system.Id != sw.System) + throw Errors.SwitchNotFoundPublic; + + if (await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(value)) + throw Errors.SameSwitchTimestampError; + + await _repo.MoveSwitch(sw.Id, value); + + var members = await _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)).ToListAsync(); + return Ok(new FrontersReturnNew + { + Uuid = sw.Uuid, + Timestamp = sw.Timestamp, + Members = members.Select(x => x.ToJson(LookupContext.ByOwner)) + }); + } + + [HttpPatch("systems/{systemRef}/switches/{switchRef}/members")] + public async Task SwitchMemberPatch(string systemRef, string switchRef, [FromBody] JArray data) + { + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + + if (!Guid.TryParse(switchRef, out var switchId)) + throw Errors.SwitchNotFound; + + if (data.Distinct().Count() != data.Count) + throw Errors.DuplicateMembersInList; + + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null) + throw Errors.SwitchNotFound; + + var members = new List(); + + foreach (var JmemberRef in data) + { + var memberRef = JmemberRef.Value(); + + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFoundWithRef(memberRef); + if (member.System != system.Id) + throw Errors.NotOwnMemberErrorWithRef(memberRef); + + members.Add(member); + } + + var latestSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); + + if (await latestSwitchMembers.Select(m => m.Hid) + .SequenceEqualAsync(members.Select(m => m.Hid).ToAsyncEnumerable())) + throw Errors.SameSwitchMembersError; + + await _db.Execute(conn => _repo.EditSwitch(conn, sw.Id, members.Select(x => x.Id).ToList())); + return Ok(new FrontersReturnNew + { + Uuid = sw.Uuid, + Timestamp = sw.Timestamp, + Members = members.Select(x => x.ToJson(LookupContext.ByOwner)) + }); + } + + [HttpDelete("systems/{systemRef}/switches/{switchRef}")] + public async Task SwitchDelete(string systemRef, string switchRef) + { + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + + if (!Guid.TryParse(switchRef, out var switchId)) + throw Errors.InvalidSwitchId; + + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null || system.Id != sw.System) + throw Errors.SwitchNotFoundPublic; + + await _repo.DeleteSwitch(sw.Id); + + return NoContent(); + } +} + +public struct PostSwitchParams +{ + public Instant? Timestamp { get; set; } + public ICollection Members { get; set; } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs new file mode 100644 index 00000000..4842e0e9 --- /dev/null +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Mvc; + +using Newtonsoft.Json.Linq; + +using PluralKit.Core; + +namespace PluralKit.API; + +[ApiController] +[Route("v2/systems")] +public class SystemControllerV2: PKControllerBase +{ + public SystemControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("{systemRef}")] + public async Task SystemGet(string systemRef) + { + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + return Ok(system.ToJson(ContextFor(system))); + } + + [HttpPatch("{systemRef}")] + public async Task DoSystemPatch(string systemRef, [FromBody] JObject data) + { + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + var patch = SystemPatch.FromJSON(data); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + var newSystem = await _repo.UpdateSystem(system.Id, patch); + return Ok(newSystem.ToJson(LookupContext.ByOwner)); + } + + [HttpGet("{systemRef}/settings")] + public async Task GetSystemSettings(string systemRef) + { + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + + var config = await _repo.GetSystemConfig(system.Id); + return Ok(config.ToJson()); + } + + [HttpPatch("{systemRef}/settings")] + public async Task DoSystemSettingsPatch(string systemRef, [FromBody] JObject data) + { + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + if (ContextFor(system) != LookupContext.ByOwner) + throw Errors.GenericMissingPermissions; + + var patch = SystemConfigPatch.FromJson(data); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); + + var newConfig = await _repo.UpdateSystemConfig(system.Id, patch); + return Ok(newConfig.ToJson()); + } +} \ No newline at end of file diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs new file mode 100644 index 00000000..3008c4c7 --- /dev/null +++ b/PluralKit.API/Errors.cs @@ -0,0 +1,137 @@ +using Newtonsoft.Json.Linq; + +using PluralKit.Core; + +namespace PluralKit.API; + +public class PKError: Exception +{ + public PKError(int code, int json_code, string message) : base(message) + { + ResponseCode = code; + JsonCode = json_code; + } + + public int ResponseCode { get; init; } + public int JsonCode { get; init; } + + public JObject ToJson() + { + var j = new JObject(); + j.Add("message", Message); + j.Add("code", JsonCode); + return j; + } +} + +public class ModelParseError: PKError +{ + public ModelParseError(IEnumerable errors) : base(400, 40001, "Error parsing JSON model") + { + _errors = errors; + } + + private IEnumerable _errors { get; } + + public new JObject ToJson() + { + var j = base.ToJson(); + var e = new JObject(); + + foreach (var err in _errors) + { + var o = new JObject(); + + if (err is FieldTooLongError fe) + { + o.Add("message", $"Field {err.Key} is too long."); + o.Add("actual_length", fe.ActualLength); + o.Add("max_length", fe.MaxLength); + } + else if (err.Text != null) + { + o.Add("message", err.Text); + } + else + { + o.Add("message", $"Field {err.Key} is invalid."); + } + + if (e[err.Key] == null) + e.Add(err.Key, new JArray()); + + (e[err.Key] as JArray).Add(o); + } + + j.Add("errors", e); + return j; + } +} + +public static class Errors +{ + public static PKError GenericBadRequest = new(400, 0, "400: Bad Request"); + public static PKError GenericAuthError = new(401, 0, "401: Missing or invalid Authorization header"); + public static PKError GenericMissingPermissions = new(403, 0, "403: Missing permissions to access this resource"); + + public static PKError SystemNotFound = new(404, 20001, "System not found."); + public static PKError MemberNotFound = new(404, 20002, "Member not found."); + public static PKError MemberNotFoundWithRef(string memberRef) => + new(404, 20003, $"Member '{memberRef}' not found."); + public static PKError GroupNotFound = new(404, 20004, "Group not found."); + public static PKError GroupNotFoundWithRef(string groupRef) => + new(404, 20005, $"Group '{groupRef}' not found."); + public static PKError MessageNotFound = new(404, 20006, "Message not found."); + public static PKError SwitchNotFound = new(404, 20007, "Switch not found."); + public static PKError SwitchNotFoundPublic = new(404, 20008, + "Switch not found, switch associated with different system, or unauthorized to view front history."); + public static PKError SystemGuildNotFound = new(404, 20009, "No system guild settings found for target guild."); + public static PKError MemberGuildNotFound = new(404, 20010, "No member guild settings found for target guild."); + + public static PKError UnauthorizedMemberList = new(403, 30001, "Unauthorized to view member list"); + public static PKError UnauthorizedGroupList = new(403, 30002, "Unauthorized to view group list"); + public static PKError UnauthorizedGroupMemberList = new(403, 30003, "Unauthorized to view group member list"); + public static PKError UnauthorizedCurrentFronters = new(403, 30004, "Unauthorized to view current fronters."); + public static PKError UnauthorizedFrontHistory = new(403, 30005, "Unauthorized to view front history."); + public static PKError NotOwnMemberError = new(403, 30006, "Target member is not part of your system."); + public static PKError NotOwnGroupError = new(403, 30007, "Target group is not part of your system."); + // todo: somehow add the memberRef to the JSON + public static PKError NotOwnMemberErrorWithRef(string memberRef) => + new(403, 30008, $"Member '{memberRef}' is not part of your system."); + public static PKError NotOwnGroupErrorWithRef(string groupRef) => + new(403, 30009, $"Group '{groupRef}' is not part of your system."); + + public static PKError MissingAutoproxyMember = + new(400, 40002, "Missing autoproxy member for member-mode autoproxy."); + public static PKError DuplicateMembersInList = new(400, 40003, "Duplicate members in member list."); + public static PKError SameSwitchMembersError = + new(400, 40004, "Member list identical to current fronter list."); + public static PKError SameSwitchTimestampError = + new(400, 40005, "Switch with provided timestamp already exists."); + public static PKError InvalidSwitchId = new(400, 40006, "Invalid switch ID."); + public static PKError MemberLimitReached = new(400, 40007, "Member limit reached."); + public static PKError GroupLimitReached = new(400, 40008, "Group limit reached."); + public static PKError PatchLatchMemberError = new(400, 40009, "Cannot patch autoproxy member with latch-mode autoproxy."); + public static PKError Unimplemented = new(501, 50001, "Unimplemented"); +} + +public static class APIErrorHandlerExt +{ + public static bool IsUserError(this Exception exc) + { + // caused by users sending an incorrect JSON type (array where an object is expected, etc) + if (exc is InvalidCastException && exc.Message.Contains("Newtonsoft.Json")) + return true; + + // Hacky parsing of timestamps results in hacky error handling. Probably fix this one at some point. + if (exc is FormatException && exc.Message.Contains("was not recognized as a valid DateTime")) + return true; + + // this happens if a user sends an empty JSON object for PATCH (or a JSON object with no valid keys) + if (exc is InvalidPatchException) + return true; + + // This may expanded at some point. + return false; + } +} \ No newline at end of file diff --git a/PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs b/PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs new file mode 100644 index 00000000..3de763e0 --- /dev/null +++ b/PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs @@ -0,0 +1,32 @@ +using Dapper; + +using PluralKit.Core; + +namespace PluralKit.API; + +public class AuthorizationTokenHandlerMiddleware +{ + private readonly RequestDelegate _next; + + public AuthorizationTokenHandlerMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext ctx, IDatabase db) + { + ctx.Request.Headers.TryGetValue("authorization", out var authHeaders); + if (authHeaders.Count > 0) + { + var systemId = await db.Execute(conn => conn.QuerySingleOrDefaultAsync( + "select id from systems where token = @token", + new { token = authHeaders[0] } + )); + + if (systemId != null) + ctx.Items.Add("SystemId", systemId); + } + + await _next.Invoke(ctx); + } +} \ No newline at end of file diff --git a/PluralKit.API/Modules.cs b/PluralKit.API/Modules.cs index 827e32b9..6295decf 100644 --- a/PluralKit.API/Modules.cs +++ b/PluralKit.API/Modules.cs @@ -1,11 +1,8 @@ using Autofac; -namespace PluralKit.API +namespace PluralKit.API; + +public class APIModule: Module { - public class APIModule: Module - { - protected override void Load(ContainerBuilder builder) - { - } - } + protected override void Load(ContainerBuilder builder) { } } \ No newline at end of file diff --git a/PluralKit.API/PluralKit.API.csproj b/PluralKit.API/PluralKit.API.csproj index ab363d32..3ef156fb 100644 --- a/PluralKit.API/PluralKit.API.csproj +++ b/PluralKit.API/PluralKit.API.csproj @@ -1,7 +1,9 @@ - net5.0 + net6.0 + annotations + enable @@ -10,28 +12,40 @@ $(NoWarn);1591 - full + full - + - <_ContentIncludedByDefault Remove="Properties\launchSettings.json" /> + <_ContentIncludedByDefault Remove="Properties\launchSettings.json" /> + + + + true + + + + + + + + + + + + + + + + + + - - - - - - - - - - + - diff --git a/PluralKit.API/Program.cs b/PluralKit.API/Program.cs index 7f7b7133..176cd10e 100644 --- a/PluralKit.API/Program.cs +++ b/PluralKit.API/Program.cs @@ -1,33 +1,55 @@ -using Autofac.Extensions.DependencyInjection; +using App.Metrics; +using App.Metrics.AspNetCore; +using App.Metrics.Formatters.Prometheus; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; +using Autofac.Extensions.DependencyInjection; using PluralKit.Core; using Serilog; -namespace PluralKit.API -{ - public class Program - { - public static void Main(string[] args) - { - InitUtils.InitStatic(); - CreateHostBuilder(args).Build().Run(); - } +namespace PluralKit.API; - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .UseServiceProviderFactory(new AutofacServiceProviderFactory()) - .UseSerilog() - .ConfigureWebHostDefaults(whb => whb - .UseConfiguration(InitUtils.BuildConfiguration(args).Build()) - .ConfigureKestrel(opts => - { - opts.ListenAnyIP(opts.ApplicationServices.GetRequiredService().Port); - }) - .UseStartup()); +public class Program +{ + public static IMetricsRoot _metrics { get; set; } + + public static async Task Main(string[] args) + { + _metrics = AppMetrics.CreateDefaultBuilder() + .OutputMetrics.AsPrometheusPlainText() + .OutputMetrics.AsPrometheusProtobuf() + .Build(); + + InitUtils.InitStatic(); + await BuildInfoService.LoadVersion(); + var host = CreateHostBuilder(args).Build(); + var config = host.Services.GetRequiredService(); + await host.Services.GetRequiredService().InitAsync(config); + await host.RunAsync(); } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureMetrics(_metrics) + .UseMetricsWebTracking() + .UseMetricsEndpoints() + .UseMetrics( + options => + { + options.EndpointOptions = endpointsOptions => + { + endpointsOptions.MetricsTextEndpointOutputFormatter = _metrics.OutputMetricsFormatters.OfType().First(); + endpointsOptions.MetricsEndpointOutputFormatter = _metrics.OutputMetricsFormatters.OfType().First(); + }; + }) + .UseServiceProviderFactory(new AutofacServiceProviderFactory()) + .UseSerilog() + .ConfigureWebHostDefaults(whb => whb + .UseConfiguration(InitUtils.BuildConfiguration(args).Build()) + .ConfigureKestrel(opts => + { + opts.ListenAnyIP(opts.ApplicationServices.GetRequiredService().Port); + }) + .UseStartup()); } \ No newline at end of file diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index 0549b312..06c7b6b8 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -1,129 +1,153 @@ -using System; -using System.IO; using System.Reflection; using Autofac; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Versioning; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.OpenApi.Models; +using Newtonsoft.Json; + using PluralKit.Core; -namespace PluralKit.API +using Serilog; + +namespace PluralKit.API; + +public class Startup { - public class Startup + public Startup(IConfiguration configuration) { - public Startup(IConfiguration configuration) + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddCors(); + services.AddControllers() + // sorry MS, this just does *more* + .AddNewtonsoftJson(opts => + { + // ... though by default it messes up timestamps in JSON + opts.SerializerSettings.DateParseHandling = DateParseHandling.None; + }) + .ConfigureApiBehaviorOptions(options => + options.InvalidModelStateResponseFactory = context => + throw Errors.GenericBadRequest + ); + + services.AddSwaggerGen(c => { - Configuration = configuration; + c.SwaggerDoc("v1.0", new OpenApiInfo { Title = "PluralKit", Version = "1.0" }); + + c.EnableAnnotations(); + c.AddSecurityDefinition("TokenAuth", + new OpenApiSecurityScheme { Name = "Authorization", Type = SecuritySchemeType.ApiKey }); + + // Exclude routes without a version, then fall back to group name matching (default behavior) + c.DocInclusionPredicate((docName, apiDesc) => + { + if (!apiDesc.RelativePath.StartsWith("v1/")) return false; + return apiDesc.GroupName == docName; + }); + + // Set the comments path for the Swagger JSON and UI. + // https://docs.microsoft.com/en-us/aspnet/core/tutorials/getting-started-with-swashbuckle?view=aspnetcore-3.1&tabs=visual-studio#customize-and-extend + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + c.IncludeXmlComments(xmlPath); + }); + services.AddSwaggerGenNewtonsoftSupport(); + + // metrics + services.AddMetricsTrackingMiddleware(); + services.AddAppMetricsCollectors(); + } + + public void ConfigureContainer(ContainerBuilder builder) + { + builder.RegisterInstance(InitUtils.BuildConfiguration(Environment.GetCommandLineArgs()).Build()) + .As(); + builder.RegisterModule(new ConfigModule("API")); + builder.RegisterModule(new LoggingModule("api", + cfg: new LoggerConfiguration().Filter.ByExcluding( + exc => exc.Exception is PKError || exc.Exception.IsUserError() + ))); + // builder.RegisterModule(new MetricsModule("API")); + builder.RegisterModule(); + builder.RegisterModule(); + } + + // 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(); + + // Only enable Swagger stuff when ASPNETCORE_ENVIRONMENT=Development (for now) + app.UseSwagger(); + app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1.0/swagger.json", "PluralKit (v1)"); }); } - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) + // add X-PluralKit-Version header + app.Use((ctx, next) => { - services.AddCors(); - services.AddAuthentication("SystemToken") - .AddScheme("SystemToken", null); - - services.AddAuthorization(options => - { - options.AddPolicy("EditSystem", p => p.RequireAuthenticatedUser().AddRequirements(new OwnSystemRequirement())); - options.AddPolicy("EditMember", p => p.RequireAuthenticatedUser().AddRequirements(new OwnSystemRequirement())); - - options.AddPolicy("ViewMembers", p => p.AddRequirements(new PrivacyRequirement(s => s.MemberListPrivacy))); - options.AddPolicy("ViewFront", p => p.AddRequirements(new PrivacyRequirement(s => s.FrontPrivacy))); - options.AddPolicy("ViewFrontHistory", p => p.AddRequirements(new PrivacyRequirement(s => s.FrontHistoryPrivacy))); - }); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - services.AddControllers() - .SetCompatibilityVersion(CompatibilityVersion.Latest) - .AddNewtonsoftJson(); // sorry MS, this just does *more* + ctx.Response.Headers.Add("X-PluralKit-Version", BuildInfoService.FullVersion); + return next(); + }); - services.AddApiVersioning(); - - services.AddVersionedApiExplorer(c => - { - c.GroupNameFormat = "'v'VV"; - c.ApiVersionParameterSource = new UrlSegmentApiVersionReader(); - c.SubstituteApiVersionInUrl = true; - }); - - services.AddSwaggerGen(c => - { - c.SwaggerDoc("v1.0", new OpenApiInfo {Title = "PluralKit", Version = "1.0"}); - - c.EnableAnnotations(); - c.AddSecurityDefinition("TokenAuth", - new OpenApiSecurityScheme {Name = "Authorization", Type = SecuritySchemeType.ApiKey}); - - // Exclude routes without a version, then fall back to group name matching (default behavior) - c.DocInclusionPredicate((docName, apiDesc) => - { - if (!apiDesc.RelativePath.StartsWith("v1/")) return false; - return apiDesc.GroupName == docName; - }); - - // Set the comments path for the Swagger JSON and UI. - // https://docs.microsoft.com/en-us/aspnet/core/tutorials/getting-started-with-swashbuckle?view=aspnetcore-3.1&tabs=visual-studio#customize-and-extend - var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; - var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); - c.IncludeXmlComments(xmlPath); - }); - services.AddSwaggerGenNewtonsoftSupport(); - } - - public void ConfigureContainer(ContainerBuilder builder) + app.UseExceptionHandler(handler => handler.Run(async ctx => { - builder.RegisterInstance(InitUtils.BuildConfiguration(Environment.GetCommandLineArgs()).Build()) - .As(); - builder.RegisterModule(new ConfigModule("API")); - builder.RegisterModule(new LoggingModule("api")); - builder.RegisterModule(new MetricsModule("API")); - builder.RegisterModule(); - builder.RegisterModule(); - } + var exc = ctx.Features.Get(); - // 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()) + // handle common ISEs that are generated by invalid user input + if (exc.Error.IsUserError()) { - app.UseDeveloperExceptionPage(); - - // Only enable Swagger stuff when ASPNETCORE_ENVIRONMENT=Development (for now) - app.UseSwagger(); - app.UseSwaggerUI(c => - { - c.SwaggerEndpoint("/swagger/v1.0/swagger.json", "PluralKit (v1)"); - }); + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsync("{\"message\":\"400: Bad Request\",\"code\":0}"); } + + else if (exc.Error is not PKError) + { + ctx.Response.StatusCode = 500; + await ctx.Response.WriteAsync("{\"message\":\"500: Internal Server Error\",\"code\":0}"); + } + + // for some reason, if we don't specifically cast to ModelParseError, it uses the base's ToJson method + else if (exc.Error is ModelParseError fe) + { + ctx.Response.StatusCode = fe.ResponseCode; + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(fe.ToJson())); + } + else { - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - //app.UseHsts(); + var err = (PKError)exc.Error; + ctx.Response.StatusCode = err.ResponseCode; + + var json = JsonConvert.SerializeObject(err.ToJson()); + await ctx.Response.WriteAsync(json); } - //app.UseHttpsRedirection(); - app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization")); - - app.UseRouting(); - app.UseAuthentication(); - app.UseAuthorization(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } + await ctx.Response.CompleteAsync(); + })); + + app.UseMiddleware(); + + //app.UseHttpsRedirection(); + app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization", "sentry-trace")); + + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + + // metrics + app.UseMetricsAllMiddleware(); } } \ No newline at end of file diff --git a/PluralKit.API/app.config b/PluralKit.API/app.config index a6ad8283..757836f3 100644 --- a/PluralKit.API/app.config +++ b/PluralKit.API/app.config @@ -1,6 +1,6 @@  - - - + + + \ No newline at end of file diff --git a/PluralKit.API/openapi.yaml b/PluralKit.API/openapi.yaml index 33653f3c..22a8ff3f 100644 --- a/PluralKit.API/openapi.yaml +++ b/PluralKit.API/openapi.yaml @@ -27,8 +27,8 @@ info: - **1.0**: (initial definition version) license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html + name: GNU Affero General Public License, Version 3 + url: https://www.gnu.org/licenses/agpl-3.0.en.html externalDocs: url: https://pluralkit.me/api diff --git a/PluralKit.API/packages.lock.json b/PluralKit.API/packages.lock.json new file mode 100644 index 00000000..230cc70b --- /dev/null +++ b/PluralKit.API/packages.lock.json @@ -0,0 +1,1448 @@ +{ + "version": 1, + "dependencies": { + "net6.0": { + "App.Metrics.AspNetCore.All": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "ZCc2GSoDdmwxvacu9Rc/2TFtMW33KPWXfRbLF9yemEKalO5CQvDtZbCs9E1dDCEofeeI2Eho0ky86Brm3lXm4g==", + "dependencies": { + "App.Metrics.AspNetCore": "4.3.0", + "App.Metrics.AspNetCore.Endpoints": "4.3.0", + "App.Metrics.AspNetCore.Hosting": "4.3.0", + "App.Metrics.AspNetCore.Mvc": "4.3.0", + "App.Metrics.AspNetCore.Routing": "4.3.0", + "App.Metrics.AspNetCore.Tracking": "4.3.0", + "App.Metrics.Extensions.Collectors": "4.3.0", + "App.Metrics.Extensions.Configuration": "4.3.0", + "App.Metrics.Extensions.DependencyInjection": "4.3.0", + "App.Metrics.Extensions.HealthChecks": "4.3.0", + "App.Metrics.Extensions.Hosting": "4.3.0", + "App.Metrics.Formatters.Json": "4.3.0" + } + }, + "App.Metrics.Prometheus": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "QhEL8zqnmOuaaSEUfQmWrqBEYt3MI3hb5Qhmlln72wUjyWzFkadA6QgzrQmG7K0lYqsj269BYcg42cL9T7wg6g==", + "dependencies": { + "App.Metrics.Formatters.Prometheus": "4.3.0" + } + }, + "App.Metrics.Reporting.Console": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "LhQQd+CTwQ6YXpg53Bnt9seGh/zKDMWF/hWPIYVUUv5163PpexIFgvH85U7J1+Yjxrka95OEv5O/uUGxPQcyHg==", + "dependencies": { + "App.Metrics.Core": "4.3.0", + "App.Metrics.Formatters.Ascii": "4.3.0" + } + }, + "Google.Protobuf": { + "type": "Direct", + "requested": "[3.13.0, )", + "resolved": "3.13.0", + "contentHash": "/6VgKCh0P59x/rYsBkCvkUanF0TeUYzwV9hzLIWgt23QRBaKHoxaaMkidEWhKibLR88c3PVCXyyrx9Xlb+Ne6w==", + "dependencies": { + "System.Memory": "4.5.2", + "System.Runtime.CompilerServices.Unsafe": "4.5.2" + } + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.37.0, )", + "resolved": "2.37.0", + "contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg==" + }, + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { + "type": "Direct", + "requested": "[3.1.0, )", + "resolved": "3.1.0", + "contentHash": "DL3tgfLLeLT6bd64MiByrvDJn27Z8DNX4KWM1Ss4ge8zitcB8inNMVCpx4w+uVvdPqkVkLgVgPWIBx/cWXYaVQ==", + "dependencies": { + "Microsoft.AspNetCore.JsonPatch": "3.1.0", + "Newtonsoft.Json": "12.0.2", + "Newtonsoft.Json.Bson": "1.0.2" + } + }, + "Microsoft.AspNetCore.Mvc.Versioning": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "d536XDKU4kRXXwSKPImb7X2viJhxwHkqneadAI6Snqd3JaxtMM6nvzhF3Br49rZT48EAJcH1oHPq2zLRBu3CcQ==" + }, + "Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "XI1Gngedu3KuhVKtxrpTr5ZjElyFpDv/XO/nyMdTdnM1lOKPJIAtFgY3hdDC6YVVqfJ72t8EE0BWIzn2/tfALQ==", + "dependencies": { + "Microsoft.AspNetCore.Mvc.Versioning": "4.2.0" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[3.4.0, )", + "resolved": "3.4.0", + "contentHash": "X18yum5NxFeiTPBw0UvbAeq/V2sFTiElNaF5b4MpvInm7a847BCX7SeDdwziEutfqOg5L+dLjWiY66LQf0vM7A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "3.1.2", + "Microsoft.Extensions.Logging": "3.1.2", + "Serilog": "2.9.0", + "Serilog.Extensions.Hosting": "3.1.0", + "Serilog.Formatting.Compact": "1.1.0", + "Serilog.Settings.Configuration": "3.1.0", + "Serilog.Sinks.Console": "3.1.1", + "Serilog.Sinks.Debug": "1.0.1", + "Serilog.Sinks.File": "4.1.0" + } + }, + "Swashbuckle.AspNetCore.Annotations": { + "type": "Direct", + "requested": "[5.6.3, )", + "resolved": "5.6.3", + "contentHash": "ucCJueBMJZ86z2w43wwdziBGdvjpkBXndSlr34Zz2dDXXfTA0kIsUbSzS/PWMCOINozJkFSWadWQ0BP+zOxQcA==", + "dependencies": { + "Swashbuckle.AspNetCore.SwaggerGen": "5.6.3" + } + }, + "Swashbuckle.AspNetCore.Filters": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "e9n8g5FerM9LEzErUQFIP2YoRK+3LMAQdpOddJgDsyHQE60886l1GSu2UnVzdzTh0TEDHJ0yIjN6ciitjs9Wdw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "2.1.0", + "Microsoft.OpenApi": "1.2.2", + "Newtonsoft.Json": "12.0.3", + "Scrutor": "3.0.1", + "Swashbuckle.AspNetCore": "5.0.0", + "Swashbuckle.AspNetCore.Annotations": "5.0.0" + } + }, + "Swashbuckle.AspNetCore.Newtonsoft": { + "type": "Direct", + "requested": "[5.6.3, )", + "resolved": "5.6.3", + "contentHash": "nLVhWdyyOoapuA6NiSBPHBZcYiPUR7PaKwDfpojI0z/E/5RTkx1cLy2Ks0pSgtsAiFtwkYPAbqIEDEB+VNIjfA==", + "dependencies": { + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "3.0.0", + "Swashbuckle.AspNetCore.SwaggerGen": "5.6.3" + } + }, + "Swashbuckle.AspNetCore.Swagger": { + "type": "Direct", + "requested": "[5.6.3, )", + "resolved": "5.6.3", + "contentHash": "rn/MmLscjg6WSnTZabojx5DQYle2GjPanSPbCU3Kw8Hy72KyQR3uy8R1Aew5vpNALjfUFm2M/vwUtqdOlzw+GA==", + "dependencies": { + "Microsoft.OpenApi": "1.2.3" + } + }, + "Swashbuckle.AspNetCore.SwaggerGen": { + "type": "Direct", + "requested": "[5.6.3, )", + "resolved": "5.6.3", + "contentHash": "CkhVeod/iLd3ikVTDOwG5sym8BE5xbqGJ15iF3cC7ZPg2kEwDQL4a88xjkzsvC9oOB2ax6B0rK0EgRK+eOBX+w==", + "dependencies": { + "Swashbuckle.AspNetCore.Swagger": "5.6.3" + } + }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Direct", + "requested": "[5.6.3, )", + "resolved": "5.6.3", + "contentHash": "BPvcPxQRMsYZ3HnYmGKRWDwX4Wo29WHh14Q6B10BB8Yfbbcza+agOC2UrBFA1EuaZuOsFLbp6E2+mqVNF/Je8A==" + }, + "App.Metrics": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "D2eDXyfrl+lXigXsQTv/81JCxUPTjgwsazK5neA3NOg87tNmBpFqeVJppI/qLKyC8yklTU2ekZDFX5hKechu6A==", + "dependencies": { + "App.Metrics.Core": "4.3.0", + "App.Metrics.Formatters.Json": "4.3.0" + } + }, + "App.Metrics.Abstractions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ekSlyVgN6foN6rmwVmRGBr0j5ufgRPsO5f7Md2fc3q44vkBNYpjsRLiUQsIXCSVI3NHorkrZh8aL4eRcLkVDGw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.0.0" + } + }, + "App.Metrics.AspNetCore": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "b9xsSzFRRMTfhZSwPxwA6AgnItIfINXVXJHtnawjWZmELByAVljqk/pt/rqBgmGdi4lm08mYD5Oa+wv//79iiA==", + "dependencies": { + "App.Metrics": "4.3.0", + "App.Metrics.AspNetCore.Endpoints": "4.3.0", + "App.Metrics.AspNetCore.Tracking": "4.3.0", + "App.Metrics.Extensions.Hosting": "4.3.0" + } + }, + "App.Metrics.AspNetCore.Abstractions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VQRn2A70HXn0KzB0OTzx4C7LjTLa2zARg4G2OkHpdlbqBQaJo7Lt1amKjzUQAdg7zEEOofr9wtzVISpV63UB9A==", + "dependencies": { + "App.Metrics.Abstractions": "4.3.0" + } + }, + "App.Metrics.AspNetCore.Core": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "Ddk6q4YeA2P23+07MXo6j4vaJtE+sY81+6jbbLBSboW9CRhO40QKUukYW+OtNfgX+PegQigHWjFLrZGt/X4sWw==", + "dependencies": { + "App.Metrics.AspNetCore.Abstractions": "4.3.0", + "App.Metrics.Core": "4.3.0", + "App.Metrics.Extensions.Configuration": "4.3.0", + "App.Metrics.Extensions.DependencyInjection": "4.3.0", + "Microsoft.Extensions.Logging.Abstractions": "3.1.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "3.1.0" + } + }, + "App.Metrics.AspNetCore.Endpoints": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "Ns/66gHqwwujWpSxrgdJH39YcNYfmd23Jon+vb+SE43VOFTBHRxer6zGJQIuFdFhePCFlT7obi5Dz9hde47jIQ==", + "dependencies": { + "App.Metrics.AspNetCore.Hosting": "4.3.0" + } + }, + "App.Metrics.AspNetCore.Hosting": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "BBb4BT6c20pT/in7jzSR0PrKXc1kwGQNLY921BRs5szJcNoNkdPbct7gzYOUed2JWMY7e2GhKNVZT9Ew1fQ9XA==", + "dependencies": { + "App.Metrics.AspNetCore.Core": "4.3.0" + } + }, + "App.Metrics.AspNetCore.Mvc": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "CvsIUrUFS6sWimxKRl9RChDtOAGY36yW3HSTNXSaUrbFpmF76qL2HKiXu+4vSpO0Xau+fk7TdJvGRiG5RWGj0A==", + "dependencies": { + "App.Metrics.AspNetCore": "4.3.0", + "App.Metrics.AspNetCore.Mvc.Core": "4.3.0", + "App.Metrics.AspNetCore.Routing": "4.3.0" + } + }, + "App.Metrics.AspNetCore.Mvc.Core": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "acAmuq4roemQv19S7xtboDqEA04NAlSsIw9F/mt51fCcjdq338qgdlEFlr3M2OCaorfS8WzMtlBPblY2/VUdWg==", + "dependencies": { + "App.Metrics.AspNetCore": "4.3.0", + "App.Metrics.AspNetCore.Routing": "4.3.0" + } + }, + "App.Metrics.AspNetCore.Routing": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "s8TMzlkvKM+zBlLYVcpH/Ofk4ftfWBvSDD6T+ehxMiY3k4entz6SVAeJTLrq2PDmO2T5vy7cYI97R0M6Fr6dpA==", + "dependencies": { + "App.Metrics.AspNetCore.Abstractions": "4.3.0" + } + }, + "App.Metrics.AspNetCore.Tracking": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "NWFmXLKEDXdkdKBN32FZXBd16Qhj6UpzQYSjmUN8XOYb+pjJQxttpTTnO8nWYHQ1xX893jx8vjZTN8vQ40j9AA==", + "dependencies": { + "App.Metrics.AspNetCore.Hosting": "4.3.0" + } + }, + "App.Metrics.Concurrency": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "otryWX9AR7wLPD49glbxvbYc16pnDOEezHsAtf5oVjhAa/fD+fjhI11MOgzBOjFpkH7z2FLl/gtZ0lwSdNxSag==" + }, + "App.Metrics.Core": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "HhW4n2fF+WBi6ctCpwsYkKCSeLhG5Y17e31kSkdESNAdPvroI9szlzW3WoY20qsB3bCldrGPPnCN6jXI1t3agA==", + "dependencies": { + "App.Metrics.Abstractions": "4.3.0", + "App.Metrics.Concurrency": "4.3.0", + "App.Metrics.Formatters.Ascii": "4.3.0", + "Microsoft.CSharp": "4.4.0" + } + }, + "App.Metrics.Extensions.Collectors": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "vpWzbLJ2uUnaR6s/bp4F1mZNf5vxMvFA0re+bUbQ8gkop7AEJZ1g3uFdQs7mSeL56josQBGnwbMediVst5zywA==", + "dependencies": { + "App.Metrics": "4.3.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.0", + "Microsoft.Extensions.Hosting.Abstractions": "3.1.0" + } + }, + "App.Metrics.Extensions.Configuration": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+5eNA58nJEvKNd6eKXXnwjjH8KU0wIN9VnE4015qoU6P/yii0tKARrF5Rbw0OGpI6jJmfZ/UIielU07b9QB8aA==", + "dependencies": { + "App.Metrics": "4.3.0", + "Microsoft.Extensions.Configuration.Binder": "3.1.0" + } + }, + "App.Metrics.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "lujWx61MSJPKdX7PiaNPv0aXW6D+UzzqiQe/2EwXv401+bshJyyrltSTVVS2cuyla+iq/ag+W1Vc/xeFR0rrwg==", + "dependencies": { + "App.Metrics": "4.3.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.0" + } + }, + "App.Metrics.Extensions.HealthChecks": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "uLpWgl9flmsDTYuYvIOjjo3tEsn3H951OS3ItS2tqi/wgGGpwAXwRW+HB/meB8W6PBRmISPQCUwNJudRerH5zA==", + "dependencies": { + "App.Metrics.Core": "4.3.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.0", + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "3.1.5" + } + }, + "App.Metrics.Extensions.Hosting": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "uSw1pD6MHoky5NCDHsmGArThHhjIXiILRv+XboZXHGA6M4DbWbPrPMsMr9uCeKKyT2wl63y8cboH8oCkC4s8yg==", + "dependencies": { + "App.Metrics.Core": "4.3.0" + } + }, + "App.Metrics.Formatters.Ascii": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "PPacBFRji8wTGv8rs13fPmAVlOit7CAvkdPkZ6aYgtUa75e0v4fYzwqPcLxokCqdQXW96PpKPfC0VZZeDkgljg==", + "dependencies": { + "App.Metrics.Abstractions": "4.3.0" + } + }, + "App.Metrics.Formatters.InfluxDB": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "N3LKXX7lcSPMOGvtOeWE0IKirT1Xq+1AHI6Jg2/NtZYdPezK3z4G1sGKflsF+cbmSojD7WSH9mFwn/Vec8QyWQ==", + "dependencies": { + "App.Metrics.Core": "4.1.0" + } + }, + "App.Metrics.Formatters.Json": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "H+4Q407Xa5nuBagooMeh5UAuGHWfKZRsinpwr9dtyV+LZbhAS5yheAAMPY1Xs/g0zzI3zJQJDRy7iX0totAcYA==", + "dependencies": { + "App.Metrics.Abstractions": "4.3.0", + "System.Text.Json": "4.7.2" + } + }, + "App.Metrics.Formatters.Prometheus": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cVJZX5jiMxt+YytjpbMw52reN47LGL3XsCljzNH9Pb+Op9iSTazc4pa+/fX+FdpbhH/Zt+5hjdYiqOLFol0wGg==", + "dependencies": { + "App.Metrics.Core": "4.3.0", + "protobuf-net": "2.4.0" + } + }, + "App.Metrics.Reporting.InfluxDB": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "4wqe8OboSLt/k1MSaqfcAx+mhArquKUZ8ObyHCVxpaTiiJuSIT5D6KMaf4GaOLjS2C5sdQLrrX87IGcvV3b2GQ==", + "dependencies": { + "App.Metrics.Abstractions": "4.1.0", + "App.Metrics.Formatters.InfluxDB": "4.1.0" + } + }, + "Autofac": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "tRVRXGxwXbQmPy1ZGso115O55ffVW4mWtufjOy7hduQ1BNVR1j7RQQjxpYuB6tJw5OrgqRWYVJLJ8RwYNz/j+A==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "4.7.1" + } + }, + "Autofac.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "7.1.0", + "contentHash": "nm6rZREZ9tjdKXu9cmT69zHkZODagjCPlRRCOhiS1VqFFis9LQnMl18L4OYr8yfCW1WAQkFDD2CNaK/kF5Eqeg==", + "dependencies": { + "Autofac": "6.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Dapper": { + "type": "Transitive", + "resolved": "2.0.35", + "contentHash": "/xAgd8BO8EDnJ0sURWEV8LptHHvTKxoYiT63YUF2U/yWE2VyUCqR2jcrtEyNngT9Kjzppecz95UKiBla3PnR7g==", + "dependencies": { + "System.Reflection.Emit.Lightweight": "4.7.0" + } + }, + "Dapper.Contrib": { + "type": "Transitive", + "resolved": "2.0.35", + "contentHash": "yVrsIV1OkdUZ8BGQbrO0EkthnPWtgs6TV2pfOtTC93G8y2BwZ0nnBJifJj+ICzN7c7COsBlVg6P6eYUwdwJj1Q==", + "dependencies": { + "Dapper": "2.0.35", + "Microsoft.CSharp": "4.7.0", + "System.Reflection.Emit": "4.7.0" + } + }, + "Elasticsearch.Net": { + "type": "Transitive", + "resolved": "7.8.1", + "contentHash": "vGHlxY72LH8/DcKb/QDpvrIelQIUFxNnXa+HmS/ifX7M7dgwmTpA2i4SagQ65gg7oi088cteUuDl4fKIystg7Q==", + "dependencies": { + "Microsoft.CSharp": "4.6.0", + "System.Buffers": "4.5.0", + "System.Diagnostics.DiagnosticSource": "4.5.1" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.8.26", + "contentHash": "OiKusGL20vby4uDEswj2IgkdchC1yQ6rwbIkZDVBPIR6al2b7n3pC91elBul9q33KaBgRKhbZH3+2Ur4fnWx2A==" + }, + "IPNetwork2": { + "type": "Transitive", + "resolved": "2.5.381", + "contentHash": "MUx9JEtZINtK8bqBAOdz8PpGMK5Rfw6NtQ6gzux95AK8EOJ2naQTRKLUOJfm9aPb0rCfZgSoVKBU4XSXUoKxRw==" + }, + "Microsoft.AspNetCore.JsonPatch": { + "type": "Transitive", + "resolved": "3.1.0", + "contentHash": "EctKEX24sGLS4Brg2hdxZQc3WwP9MKFvk0d1oa8ilyt8q0rgo73G6ptxQvkFmQwaG+SKnkVV31T2UN3nSYhPGA==", + "dependencies": { + "Microsoft.CSharp": "4.7.0", + "Newtonsoft.Json": "12.0.2" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "K63Y4hORbBcKLWH5wnKgzyn7TOfYzevIEwIedQHBIkmkEBA9SCqgvom+XTuE+fAFGvINGkhFItaZ2dvMGdT5iw==" + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + }, + "Microsoft.DotNet.PlatformAbstractions": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "9KPDwvb/hLEVXYruVHVZ8BkebC8j17DmPb56LnqRF74HqSPLjCkrlFUjOtFpQPA2DeADBRTI/e69aCfRBfrhxw==", + "dependencies": { + "System.AppContext": "4.1.0", + "System.Collections": "4.0.11", + "System.IO": "4.1.0", + "System.IO.FileSystem": "4.0.1", + "System.Reflection.TypeExtensions": "4.1.0", + "System.Runtime.Extensions": "4.1.0", + "System.Runtime.InteropServices": "4.1.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.0.0" + } + }, + "Microsoft.Extensions.ApiDescription.Server": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "LH4OE/76F6sOCslif7+Xh3fS/wUUrE5ryeXAMcoCnuwOQGT5Smw0p57IgDh/pHgHaGz/e+AmEQb7pRgb++wt0w==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "xdl25cxDgwVxF9ckD9vJ5AdjzRE1vTGLYj9kZf6aL317ZneUijkxd/nSuzN1gEuO74dwG/Yfr1zfs636D6YZsA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "3.1.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "pR6mRkJx67/itEnEpnBiiATeH/P6RnhqvriD6RdQsXepO+uisfUrd149CTGPc1G5J0Qf9bwSCJkb/MYkuQ6mqw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "3.1.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.10", + "Microsoft.Extensions.Logging.Abstractions": "3.1.10", + "Microsoft.Extensions.Options": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "HHBhCP3wAJe7UIXjim0wFXty0WG/rZAP3aZyy03uuaxiOOPHJjbUdY6K9qkfQuP+hsRzfiT+np5k4rFmcSo3og==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "UEfngyXt8XYhmekUza9JsWlA37pNOtZAjcK5EEKQrHo2LDKJmZVmcyAUFlkzCcf97OSr+w/MiDLifDDNQk9agw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "B9nQBk0GZVkOgSB1oB9V/7kvxhBvLCqm2x4m8MIoSxrd9yga8MVq2HWqnai8zZdH1WL6OlOG5mCVrwgAVwNNJg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "RsN2xbSa7Gre429++1G2DkdAgCvVIYmJxC2L+tRmGLe/R3FOt0zH8Vri7ZmZkoOxQXks2oxqEYdGeUa1u/2NtA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "6L8lamClsrfofdWEEIFZzGx0TLfFKRRilXsdjn6Mzu73OeOZ6r6shBCYsAe38cx9JzqBLHh5l0slGBhh0yMCEw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "9//fdlaFDxkfjYPgdwYySJCtjVNTYFqnqX07Oai0eendh+Jl/SfmSAwrXyMTNgRv+jWJ2fQs85MG0cK7nAoGdQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "3.1.10", + "Microsoft.Extensions.FileProviders.Physical": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "HZRvVDPpYXYtZI2zA/xuzBeA7lOPXfhXNsPiMq3O7QhLuXIGoyeRN3Ssxh9uOA+wLjTQLZQVTmzQutTWwVyuvg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "3.1.10", + "Microsoft.Extensions.Configuration.FileExtensions": "3.1.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "fla8hKhQmld2s/7arhUxlu3dzZLBFJLg4BQiQZdqKND4MlmnMU9jhoxY4MMlSYl6MtxumtwASHMJnuV9f96IQQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "bhjtAN7Ix5WOAr47RK16Lr1l2eizSBMCYQSavkooZyf6Xdf8XWAYGWsGsPqUFOeeRxzhpRho051rXaLn5wskVw==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "nS2XKqi+1A1umnYNLX2Fbm/XnzCxs5i+zXVJ3VC6r9t2z0NZr9FLnJN4VQpKigdcWH/iFTbMuX6M6WQJcTjVIg==", + "dependencies": { + "Microsoft.DotNet.PlatformAbstractions": "2.1.0", + "Newtonsoft.Json": "9.0.1", + "System.Diagnostics.Debug": "4.0.11", + "System.Dynamic.Runtime": "4.0.11", + "System.Linq": "4.1.0" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "3.1.5", + "contentHash": "6oo7BLy4cdYGegZJ2d3YXUFT9Pb1Pp2kq8QuTSG7oZOQ6nF0QgHMwJPX/zQqTeWVDbA+UsFaZ4QNyUGHdG5VEg==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "Pwu/7fpw1G7WjO1DxPmGfrw6ciruiLHH6k26uNex9Sn/s229uKcwds7GTBUAPbpoh4MI3qo21nqmLBo3N7gVfg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "3.1.10" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "5zIjKJGUtuBSvPSOZEiX1MnuOjSl9L4jv1+f24lO076wtZ6cBTQ34EN0jbwUYJgRX1C4ZgoSdwFZ1ZBSo61zxQ==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "3.1.10", + "Microsoft.Extensions.FileSystemGlobbing": "3.1.10" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "TzHIUBWnzsViPS/20DnC6wf5kXdRAUZlIYwTYOT9S6heuOA4Re//UmHWsDR3PusAzly5dkdDW0RV0dDZ2vEebQ==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "3.1.0", + "contentHash": "LiOP1ceFaPBxaE28SOjtORzOVCJk33TT5VQ/Cg5EoatZh1dxpPAgAV/0ruzWKQE7WAHU3F1H9Z6rFgsQwIb9uQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "3.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.0", + "Microsoft.Extensions.FileProviders.Abstractions": "3.1.0", + "Microsoft.Extensions.Logging.Abstractions": "3.1.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "GjP+4cUFdsNk/Px6BlJ7p7x7ibpawcaAV4tfrRJTv2s6Nb7yz5OEKA0kbNl1ZXKa6uMQzbNqc5+B/tJsqzgIXg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "3.1.10", + "Microsoft.Extensions.DependencyInjection": "3.1.10", + "Microsoft.Extensions.Logging.Abstractions": "3.1.10", + "Microsoft.Extensions.Options": "3.1.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "bKHbgzbGsPZbEaExRaJqBz3WQ1GfhMttM23e1nivLJ8HbA3Ad526mW2G2K350q3Dc3HG83I5W8uSZWG4Rv4IpA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "CusdV4eIv+CGb9Fy6a+JcRqpcVJREmvFI8eHk3nQ76VLtEAIJpKQY5r5sRSs5w6NevNi2ukdnKleH0YCPudFZQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.10", + "Microsoft.Extensions.Primitives": "3.1.10" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "3.1.0", + "contentHash": "tx6gMKE3rDspA1YZT8SlQJmyt1BaBSl6mNjB3g0ZO6m3NnoavCifXkGeBuDk9Ae4XjW8C+dty52p+0u38jPRIQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "3.1.0", + "Microsoft.Extensions.Configuration.Binder": "3.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.0", + "Microsoft.Extensions.Options": "3.1.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "YDuQS3BeaVY6PCWUm5f6qFTYsxhwntQrcfwUzbohU/0rZBL5XI+UsD5SgggHKHX+rFY4laaT428q608Sw/mDsw==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" + }, + "Microsoft.NETCore.Targets": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "1.2.3", + "contentHash": "Nug3rO+7Kl5/SBAadzSMAVgqDlfGjJZ0GenQrLywJ84XGKO0uRqkunz5Wyl0SDwcR71bAATXvSdbdzPrYRYKGw==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Bh6blKG8VAKvXiLe2L+sEsn62nc1Ij34MrNxepD2OCrS5cpCwQa9MeLyhVQPQ/R4Wlzwuy6wMK8hLb11QPDRsQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "12.0.3", + "contentHash": "6mgjfnRB4jKMlzHSl+VD+oUc1IebOZabkbyWj2RiTgWwYPPuaK1H97G1sHqGwPlS5npiF5Q0OrxN1wni2n5QWg==" + }, + "Newtonsoft.Json.Bson": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "QYFyxhaABwmq3p/21VrZNYvCg3DaEoN/wUuw5nmfAf0X3HLjgupwhkEWdgfb9nvGAUIv3osmZoD3kKl4jxEmYQ==", + "dependencies": { + "Newtonsoft.Json": "12.0.1" + } + }, + "NodaTime": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "sTXjtPsRddI6iaRL2iT80zBOiHTnSCy2rEHxobUKvRhr5nt7BbSIPb4cGtVf202OW0glaJMLr/5xg79FIFMHsA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.7.1" + } + }, + "NodaTime.Serialization.JsonNet": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "buSE64oL5eHDiImMgFRF74X/URygSpklFsblyqXafjSW6lMsB7iWfGO5lu7D7Zikj9bXggnMa90a5EqgpPJEYg==", + "dependencies": { + "Newtonsoft.Json": "12.0.1", + "NodaTime": "[3.0.0, 4.0.0)" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "4.1.5", + "contentHash": "juDlNse+SKfXRP0VSgpJkpdCcaVLZt8m37EHdRX+8hw+GG69Eat1Y0MdEfl+oetdOnf9E133GjIDEjg9AF6HSQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.6.0" + } + }, + "Npgsql.NodaTime": { + "type": "Transitive", + "resolved": "4.1.5", + "contentHash": "Rz3Lm8ijL0CQXvl9ZlYFsW70CiC+5D5D4m8KE7CwSsgpaB+FmpP2q3hwqoHWXqUKyWiuI2lglrI7pUuaySMTag==", + "dependencies": { + "NodaTime": "2.4.7", + "Npgsql": "4.1.5" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "7hzHplEIVOGBl5zOQZGX/DiJDHjq+RVRVrYgDiqXb6RriqWAdacXxp+XO9WSrATCEXyNOUOQg9aqQArsjase/A==", + "dependencies": { + "System.IO.Pipelines": "5.0.0" + } + }, + "protobuf-net": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "j37MD1p1s9NdX8P5+IaY2J9p2382xiL1VP3mxYu0g+G/kf2YM2grFa1jJPO+0WDJNl1XhNPO0Q5yBEcbX77hBQ==", + "dependencies": { + "System.ServiceModel.Primitives": "4.5.3" + } + }, + "runtime.native.System": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "Scrutor": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "biheXROWXbciLzPOg/PttVH4w4Q8ADx89bQP8eKiGf1IJj0EOLYRjoctsMGQzi4mB+e4ICMqFeA8Spr0NKN4ZA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1", + "Microsoft.Extensions.DependencyModel": "2.1.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "2.10.0", + "contentHash": "+QX0hmf37a0/OZLxM3wL7V6/ADvC1XihXN4Kq/p6d8lCPfgkRdiuhbWlMaFjR9Av0dy5F0+MBeDmDdRZN/YwQA==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "3.1.0", + "contentHash": "+NnmORRm9Tzzb9ZY9mgLEr9TRdayaOUdiegq9/4Bv8MSDpBeydxF+X3ea5riui1EzGUId+hpwy7j1hqcXs5Cdw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Hosting.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Serilog": "2.8.0", + "Serilog.Extensions.Logging": "3.0.1" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "U0xbGoZuxJRjE3C5vlCfrf9a4xHTmbrCXKmaA14cHAqiT1Qir0rkV7Xss9GpPJR3MRYH19DFUUqZ9hvWeJrzdQ==", + "dependencies": { + "Microsoft.Extensions.Logging": "2.0.0", + "Serilog": "2.8.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "pNroKVjo+rDqlxNG5PXkRLpfSCuDOBY0ri6jp9PLe505ljqwhwZz8ospy2vWhQlFu5GkIesh3FcDs4n7sWZODA==", + "dependencies": { + "Serilog": "2.8.0" + } + }, + "Serilog.Formatting.Elasticsearch": { + "type": "Transitive", + "resolved": "8.4.1", + "contentHash": "768KS00+XwQSxVIYKJ4KWdqyLd5/w3DKndf+94U8NCk7qpXCeZl4HlczsDeyVsNPTyRF6MVss6Wr9uj4rhprfA==", + "dependencies": { + "Serilog": "2.8.0" + } + }, + "Serilog.NodaTime": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "F+eeRNlJZK3g9z2+c/v/WZTGHeqmnwOseQ0jMiVnW2XiKRLY9hLBopBRPbmdkhQNYtYpO9PTjcVRMHQ0Z44MmA==", + "dependencies": { + "NodaTime": "[3.0.0, 4.0.0)", + "Serilog": "2.9.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "3.1.0", + "contentHash": "BS+G1dhThTHBOYm8R21JNlR+Nh7ETAOlJuL1P6te1rOG98eV1vos5EyWRTGr0AbHgySxsGu1Q/evfFxS9+Gk1Q==", + "dependencies": { + "Microsoft.Extensions.DependencyModel": "2.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "2.0.0", + "Serilog": "2.6.0" + } + }, + "Serilog.Sinks.Async": { + "type": "Transitive", + "resolved": "1.4.1-dev-00071", + "contentHash": "6fSXIPZuJUolE0mboqHE+pHOVZdW5vxqM1lbicz3giKtwOdycOAr9vz6oQzGPHUhGZOz4JJeymw39/G+Q5dwvw==", + "dependencies": { + "Serilog": "2.8.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Transitive", + "resolved": "4.0.0-dev-00834", + "contentHash": "DrM9ibdcrKCi1IQOEY764Z84uCH7mrLGy6P0zHpT8Ha6k3KyepDDDujmAf5XquOK97VrGRfyaFxnr8b42hcUgw==", + "dependencies": { + "Serilog": "2.8.0", + "System.Console": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "1.0.1", + "contentHash": "nE5wvw9+J/V4lA+rEkFUETGjBabK8IlLQY5Z9KDzoo5LvILC4vhTOXLs7DGYs8h5juIf2nLZnVxHDXf404FqEQ==", + "dependencies": { + "Serilog": "2.5.0", + "System.Diagnostics.Debug": "4.3.0" + } + }, + "Serilog.Sinks.Elasticsearch": { + "type": "Transitive", + "resolved": "8.4.1", + "contentHash": "SM17WdHUshJSm44uC45jEUW4Wzp9wCltbWry5iY5fNgxJ3PkIkW6I8p+WviU5lx/bayCvAoB5uO07UK2qjBSAQ==", + "dependencies": { + "Elasticsearch.Net": "7.8.1", + "Microsoft.CSharp": "4.6.0", + "Serilog": "2.8.0", + "Serilog.Formatting.Compact": "1.0.0", + "Serilog.Formatting.Elasticsearch": "8.4.1", + "Serilog.Sinks.File": "4.0.0", + "Serilog.Sinks.PeriodicBatching": "2.1.1", + "System.Diagnostics.DiagnosticSource": "4.5.1" + } + }, + "Serilog.Sinks.File": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "U0b34w+ZikbqWEZ3ui7BdzxY/19zwrdhLtI3o6tfmLdD3oXxg7n2TZJjwCCTlKPgRuYic9CBWfrZevbb70mTaw==", + "dependencies": { + "Serilog": "2.5.0", + "System.IO.FileSystem": "4.0.1", + "System.Text.Encoding.Extensions": "4.0.11", + "System.Threading.Timer": "4.0.1" + } + }, + "Serilog.Sinks.PeriodicBatching": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "L1iZtcEzQdEIYCPvhYJYB2RofPg+i1NhHJfS+DpXLyLSMS6OXebqaI1fxWhmJRIjD9D9BuXi23FkZTQDiP7cHw==", + "dependencies": { + "Serilog": "2.0.0", + "System.Collections.Concurrent": "4.0.12", + "System.Threading.Timer": "4.0.1" + } + }, + "SqlKata": { + "type": "Transitive", + "resolved": "2.3.7", + "contentHash": "erKffEMhrS2IFKXjYV83M4uc1IOCl91yeP/3uY5yIm6pRNFDNrqnTk3La1en6EGDlMRol9abTNO1erQCYf08tg==", + "dependencies": { + "System.Collections.Concurrent": "4.3.0" + } + }, + "SqlKata.Execution": { + "type": "Transitive", + "resolved": "2.3.7", + "contentHash": "LybTYj99riLRH7YQNt9Kuc8VpZOvaQ7H4sQBrj2zefktS8LASOaXsHRYC/k8NEcj25w6huQpOi+HrEZ5qHXl0w==", + "dependencies": { + "Humanizer.Core": "2.8.26", + "SqlKata": "2.3.7", + "dapper": "1.50.5" + } + }, + "StackExchange.Redis": { + "type": "Transitive", + "resolved": "2.2.88", + "contentHash": "JJi1jcO3/ZiamBhlsC/TR8aZmYf+nqpGzMi0HRRCy5wJkUPmMnRp0kBA6V84uhU8b531FHSdTDaFCAyCUJomjA==", + "dependencies": { + "Pipelines.Sockets.Unofficial": "2.2.0", + "System.Diagnostics.PerformanceCounter": "5.0.0" + } + }, + "Swashbuckle.AspNetCore": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "HoJbhDNyeDqr2R1H3YhtPqGacxgZKBFBS6g5U3tlJpv80G/IHW8hHbcnHSTXZpcatnD+xh8UiUrKp4Ua857LSQ==", + "dependencies": { + "Microsoft.Extensions.ApiDescription.Server": "3.0.0", + "Swashbuckle.AspNetCore.Swagger": "5.0.0", + "Swashbuckle.AspNetCore.SwaggerGen": "5.0.0", + "Swashbuckle.AspNetCore.SwaggerUI": "5.0.0" + } + }, + "System.AppContext": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "3QjO4jNV7PdKkmQAVp9atA+usVnKRwI3Kx1nMwJ93T0LcQfx7pKAYk0nKz5wn1oP5iqlhZuy6RXOFdhr7rDwow==", + "dependencies": { + "System.Runtime": "4.1.0" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "pL2ChpaRRWI/p4LXyy4RgeWlYF2sgfj/pnVMvBqwNFr5cXg7CXNnWZWxrOONLg8VGdFB8oB+EG2Qw4MLgTOe+A==" + }, + "System.Collections": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Collections.Concurrent": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "aM7cbfEfVNlEEOj3DsZP+2g9NRwbkyiAv2isQEzw7pnkDg9ekCU2m1cdJLM02Uq691OaCS91tooaxcEn8d0q5w==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "5.0.0", + "System.Security.Permissions": "5.0.0" + } + }, + "System.Console": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Diagnostics.Debug": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "4.7.1", + "contentHash": "j81Lovt90PDAq8kLpaJfJKV/rWdWuEk6jfV+MBkee33vzYLEUsy4gXK8laa9V2nZlLM9VM9yA/OOQxxPEJKAMw==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "kcQWWtGVC3MWMNXdMDWfrmIlFZZ2OdoeT6pSNVRtk9+Sa7jwdPiMlNwb0ZQcS7NRlT92pCfmjRtkSWUW3RAKwg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "Microsoft.Win32.Registry": "5.0.0", + "System.Configuration.ConfigurationManager": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Diagnostics.Tracing": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SztFwAnpfKC8+sEKXAFxCBWhKQaEd97EiOL7oZJZP56zbqnLpmxACWA8aGseaUExciuEAUuR9dY8f7HkTRAdnw==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "5.0.0" + } + }, + "System.Dynamic.Runtime": { + "type": "Transitive", + "resolved": "4.0.11", + "contentHash": "db34f6LHYM0U0JpE+sOmjar27BnqTVkbLJhgfwMpTdgTigG/Hna3m2MYVwnFzGGKnEJk2UXFuoVTr8WUbU91/A==", + "dependencies": { + "System.Collections": "4.0.11", + "System.Diagnostics.Debug": "4.0.11", + "System.Globalization": "4.0.11", + "System.Linq": "4.1.0", + "System.Linq.Expressions": "4.1.0", + "System.ObjectModel": "4.0.12", + "System.Reflection": "4.1.0", + "System.Reflection.Emit": "4.0.1", + "System.Reflection.Emit.ILGeneration": "4.0.1", + "System.Reflection.Primitives": "4.0.1", + "System.Reflection.TypeExtensions": "4.1.0", + "System.Resources.ResourceManager": "4.0.1", + "System.Runtime": "4.1.0", + "System.Runtime.Extensions": "4.1.0", + "System.Threading": "4.0.11" + } + }, + "System.Globalization": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "QaqhQVDiULcu4vm6o89+iP329HcK44cETHOYgy/jfEjtzeFy0ZxmuM7nel9ocjnKxEM4yh1mli7hgh8Q9o+/Iw==", + "dependencies": { + "System.Linq.Async": "5.0.0" + } + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.FileSystem": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "IBErlVq5jOggAD69bg1t0pJcHaDbJbWNUZTPI96fkYWzwYbN6D9wRHMULLDd9dHsl7C2YsxXL31LMfPI1SWt8w==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.0.1", + "Microsoft.NETCore.Targets": "1.0.1", + "System.IO": "4.1.0", + "System.IO.FileSystem.Primitives": "4.0.1", + "System.Runtime": "4.1.0", + "System.Runtime.Handles": "4.0.1", + "System.Text.Encoding": "4.0.11", + "System.Threading.Tasks": "4.0.11" + } + }, + "System.IO.FileSystem.Primitives": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "kWkKD203JJKxJeE74p8aF8y4Qc9r9WQx4C0cHzHPrY3fv/L/IhWnyCHaFJ3H1QPOH6A93whlQ2vG5nHlBDvzWQ==", + "dependencies": { + "System.Runtime": "4.1.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "irMYm3vhVgRsYvHTU5b2gsT2CwT/SMM6LZFzuJjpIvT5Z4CshxNsaoBC1X/LltwuR3Opp8d6jOS/60WwOb7Q2Q==" + }, + "System.Linq": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "bQ0iYFOQI0nuTnt+NQADns6ucV4DUvMdwN6CbkB1yj8i7arTGiTN5eok1kQwdnnNWSDZfIUySQY+J3d5KjWn0g==", + "dependencies": { + "System.Collections": "4.0.11", + "System.Diagnostics.Debug": "4.0.11", + "System.Resources.ResourceManager": "4.0.1", + "System.Runtime": "4.1.0", + "System.Runtime.Extensions": "4.1.0" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==" + }, + "System.Linq.Expressions": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "I+y02iqkgmCAyfbqOmSDOgqdZQ5tTj80Akm5BPSS8EeB0VGWdy6X1KCoYe8Pk6pwDoAKZUOdLVxnTJcExiv5zw==", + "dependencies": { + "System.Collections": "4.0.11", + "System.Diagnostics.Debug": "4.0.11", + "System.Globalization": "4.0.11", + "System.IO": "4.1.0", + "System.Linq": "4.1.0", + "System.ObjectModel": "4.0.12", + "System.Reflection": "4.1.0", + "System.Reflection.Emit": "4.0.1", + "System.Reflection.Emit.ILGeneration": "4.0.1", + "System.Reflection.Emit.Lightweight": "4.0.1", + "System.Reflection.Extensions": "4.0.1", + "System.Reflection.Primitives": "4.0.1", + "System.Reflection.TypeExtensions": "4.1.0", + "System.Resources.ResourceManager": "4.0.1", + "System.Runtime": "4.1.0", + "System.Runtime.Extensions": "4.1.0", + "System.Threading": "4.0.11" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.2", + "contentHash": "fvq1GNmUFwbKv+aLVYYdgu/+gc8Nu9oFujOxIjPrsf+meis9JBzTPDL6aP/eeGOz9yPj6rRLUbOjKMpsMEWpNg==" + }, + "System.ObjectModel": { + "type": "Transitive", + "resolved": "4.0.12", + "contentHash": "tAgJM1xt3ytyMoW4qn4wIqgJYm7L7TShRZG4+Q4Qsi2PCcj96pXN7nRywS9KkB3p/xDUjc2HSwP9SROyPYDYKQ==", + "dependencies": { + "System.Collections": "4.0.11", + "System.Diagnostics.Debug": "4.0.11", + "System.Resources.ResourceManager": "4.0.1", + "System.Runtime": "4.1.0", + "System.Threading": "4.0.11" + } + }, + "System.Private.ServiceModel": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "ancrQgJagx+yC4SZbuE+eShiEAUIF0E1d21TRSoy1C/rTwafAVcBr/fKibkq5TQzyy9uNil2tx2/iaUxsy0S9g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "2.1.0", + "System.Reflection.DispatchProxy": "4.5.0", + "System.Security.Principal.Windows": "4.5.0" + } + }, + "System.Reflection": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.DispatchProxy": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "+UW1hq11TNSeb+16rIk8hRQ02o339NFyzMc4ma/FqmxBzM30l1c2IherBB4ld1MNcenS48fz8tbt50OW4rVULA==" + }, + "System.Reflection.Emit": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "VR4kk8XLKebQ4MZuKuIni/7oh+QGFmZW3qORd1GvBq/8026OpW501SzT/oypwiQl4TvT8ErnReh/NzY9u+C6wQ==" + }, + "System.Reflection.Emit.ILGeneration": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Ov6dU8Bu15Bc7zuqttgHF12J5lwSWyTf1S+FJouUXVMSqImLZzYaQ+vRr1rQ0OZ0HqsrwWl4dsKHELckQkVpgA==", + "dependencies": { + "System.Reflection": "4.1.0", + "System.Reflection.Primitives": "4.0.1", + "System.Runtime": "4.1.0" + } + }, + "System.Reflection.Emit.Lightweight": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "a4OLB4IITxAXJeV74MDx49Oq2+PsF6Sml54XAFv+2RyWwtDBcabzoxiiJRhdhx+gaohLh4hEGCLQyBozXoQPqA==" + }, + "System.Reflection.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.TypeExtensions": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "tsQ/ptQ3H5FYfON8lL4MxRk/8kFyE0A+tGPXmVP967cT/gzLHYxIejIYSxp4JmIeFHVP78g/F2FE1mUUTbDtrg==", + "dependencies": { + "System.Reflection": "4.1.0", + "System.Runtime": "4.1.0" + } + }, + "System.Resources.ResourceManager": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.7.1", + "contentHash": "zOHkQmzPCn5zm/BH+cxC1XbUS3P4Yoi3xzW7eRgVpDR2tPGSzyMZ17Ig1iRkfJuY0nhxkQQde8pgePNiA7z7TQ==" + }, + "System.Runtime.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.Handles": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.InteropServices": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Threading": "4.3.0", + "runtime.native.System": "4.3.0" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "HGxMSAFAPLNoxBvSfW08vHde0F9uh7BjASwu6JF9JnXuEPhCY3YUqURn0+bQV/4UWeaqymmrHWV+Aw9riQCtCA==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "uE8juAhEkp7KDBCdjDIE3H9R1HJuEHqeqX8nLX9gmYKWwsqk3T5qZlPx8qle5DPKimC/Fy3AFTdV7HamgCh9qQ==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Windows.Extensions": "5.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.ServiceModel.Primitives": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "Wc9Hgg4Cmqi416zvEgq2sW1YYCGuhwWzspDclJWlFZqY6EGhFUPZU+kVpl5z9kAgrSOQP7/Uiik+PtSQtmq+5A==", + "dependencies": { + "System.Private.ServiceModel": "4.5.3" + } + }, + "System.Text.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Text.Encoding.Extensions": { + "type": "Transitive", + "resolved": "4.0.11", + "contentHash": "jtbiTDtvfLYgXn8PTfWI+SiBs51rrmO4AAckx4KR6vFK9Wzf6tI8kcRdsYQNwriUeQ1+CtQbM1W4cMbLXnj/OQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.0.1", + "Microsoft.NETCore.Targets": "1.0.1", + "System.Runtime": "4.1.0", + "System.Text.Encoding": "4.0.11" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" + }, + "System.Threading": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", + "dependencies": { + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Tasks": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Threading.Timer": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "saGfUV8uqVW6LeURiqxcGhZ24PzuRNaUBtbhVeuUAvky1naH395A/1nY0P2bWvrw/BreRtIB/EzTDkGBpqCwEw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.0.1", + "Microsoft.NETCore.Targets": "1.0.1", + "System.Runtime": "4.1.0" + } + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "c1ho9WU9ZxMZawML+ssPKZfdnrg/OjR3pe0m9v8230z3acqphwvPJqzAkH54xRYm5ntZHGG1EPP3sux9H3qSPg==", + "dependencies": { + "System.Drawing.Common": "5.0.0" + } + }, + "pluralkit.core": { + "type": "Project", + "dependencies": { + "App.Metrics": "4.1.0", + "App.Metrics.Reporting.InfluxDB": "4.1.0", + "Autofac": "6.0.0", + "Autofac.Extensions.DependencyInjection": "7.1.0", + "Dapper": "2.0.35", + "Dapper.Contrib": "2.0.35", + "Google.Protobuf": "3.13.0", + "Microsoft.Extensions.Caching.Memory": "3.1.10", + "Microsoft.Extensions.Configuration": "3.1.10", + "Microsoft.Extensions.Configuration.Binder": "3.1.10", + "Microsoft.Extensions.Configuration.CommandLine": "3.1.10", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "3.1.10", + "Microsoft.Extensions.Configuration.Json": "3.1.10", + "Microsoft.Extensions.DependencyInjection": "3.1.10", + "Microsoft.Extensions.Logging": "3.1.10", + "Newtonsoft.Json": "12.0.3", + "NodaTime": "3.0.3", + "NodaTime.Serialization.JsonNet": "3.0.0", + "Npgsql": "4.1.5", + "Npgsql.NodaTime": "4.1.5", + "Serilog": "2.10.0", + "Serilog.Extensions.Logging": "3.0.1", + "Serilog.Formatting.Compact": "1.1.0", + "Serilog.NodaTime": "3.0.0", + "Serilog.Sinks.Async": "1.4.1-dev-00071", + "Serilog.Sinks.Console": "4.0.0-dev-00834", + "Serilog.Sinks.Elasticsearch": "8.4.1", + "Serilog.Sinks.File": "4.1.0", + "SqlKata": "2.3.7", + "SqlKata.Execution": "2.3.7", + "StackExchange.Redis": "2.2.88", + "System.Interactive.Async": "5.0.0", + "ipnetwork2": "2.5.381" + } + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index f60ca6c9..cfcfe589 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,18 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; - using App.Metrics; using Autofac; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; -using DSharpPlus.Exceptions; +using Myriad.Cache; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Types; using NodaTime; @@ -23,191 +16,267 @@ using Sentry; using Serilog; using Serilog.Context; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Bot { - public class Bot + private readonly IDiscordCache _cache; + + private readonly Cluster _cluster; + private readonly PeriodicStatCollector _collector; + private readonly CommandMessageService _commandMessageService; + private readonly BotConfig _config; + private readonly ErrorMessageService _errorMessageService; + private readonly ILogger _logger; + private readonly IMetrics _metrics; + private readonly DiscordApiClient _rest; + private readonly RedisService _redis; + private readonly ILifetimeScope _services; + + private Timer _periodicTask; // Never read, just kept here for GC reasons + + public Bot(ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics, + BotConfig config, RedisService redis, + ErrorMessageService errorMessageService, CommandMessageService commandMessageService, + Cluster cluster, DiscordApiClient rest, IDiscordCache cache) { - private readonly DiscordShardedClient _client; - private readonly ILogger _logger; - private readonly ILifetimeScope _services; - private readonly PeriodicStatCollector _collector; - private readonly IMetrics _metrics; - private readonly ErrorMessageService _errorMessageService; - private readonly CommandMessageService _commandMessageService; + _logger = logger.ForContext(); + _services = services; + _collector = collector; + _metrics = metrics; + _config = config; + _errorMessageService = errorMessageService; + _commandMessageService = commandMessageService; + _cluster = cluster; + _rest = rest; + _redis = redis; + _cache = cache; + } - private bool _hasReceivedReady = false; - private Timer _periodicTask; // Never read, just kept here for GC reasons + private string BotStatus => $"{(_config.Prefixes ?? BotConfig.DefaultPrefixes)[0]}help" + + (CustomStatusMessage != null ? $" | {CustomStatusMessage}" : ""); + public string CustomStatusMessage = null; - public Bot(DiscordShardedClient client, ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics, - ErrorMessageService errorMessageService, CommandMessageService commandMessageService) + public void Init() + { + _cluster.EventReceived += (shard, evt) => OnEventReceived(shard.ShardId, evt); + _cluster.DiscordPresence = new GatewayStatusUpdate { - _client = client; - _logger = logger.ForContext(); - _services = services; - _collector = collector; - _metrics = metrics; - _errorMessageService = errorMessageService; - _commandMessageService = commandMessageService; - } - - public void Init() - { - // HandleEvent takes a type parameter, automatically inferred by the event type - // It will then look up an IEventHandler in the DI container and call that object's handler method - // For registering new ones, see Modules.cs - _client.MessageCreated += HandleEvent; - _client.MessageDeleted += HandleEvent; - _client.MessageUpdated += HandleEvent; - _client.MessagesBulkDeleted += HandleEvent; - _client.MessageReactionAdded += HandleEvent; - - // Update shard status for shards immediately on connect - _client.Ready += (client, _) => + Status = GatewayStatusUpdate.UserStatus.Online, + Activities = new[] { - _hasReceivedReady = true; - return UpdateBotStatus(client); - }; - _client.Resumed += (client, _) => UpdateBotStatus(client); - - // Init the shard stuff - _services.Resolve().Init(); + new Activity + { + Type = ActivityType.Game, + Name = BotStatus + } + } + }; - // Not awaited, just needs to run in the background - // Trying our best to run it at whole minute boundaries (xx:00), with ~250ms buffer - // This *probably* doesn't matter in practice but I jut think it's neat, y'know. - var timeNow = SystemClock.Instance.GetCurrentInstant(); - var timeTillNextWholeMinute = TimeSpan.FromMilliseconds(60000 - timeNow.ToUnixTimeMilliseconds() % 60000 + 250); - _periodicTask = new Timer(_ => - { - var __ = UpdatePeriodic(); - }, null, timeTillNextWholeMinute, TimeSpan.FromMinutes(1)); - } + _services.Resolve().OnEventReceived += (e) => OnEventReceivedInner(e.Item1, e.Item2); - public async Task Shutdown() + // Init the shard stuff + _services.Resolve().Init(); + + // Not awaited, just needs to run in the background + // Trying our best to run it at whole minute boundaries (xx:00), with ~250ms buffer + // This *probably* doesn't matter in practice but I jut think it's neat, y'know. + var timeNow = SystemClock.Instance.GetCurrentInstant(); + var timeTillNextWholeMinute = TimeSpan.FromMilliseconds(60000 - timeNow.ToUnixTimeMilliseconds() % 60000 + 250); + _periodicTask = new Timer(_ => { - // This will stop the timer and prevent any subsequent invocations - await _periodicTask.DisposeAsync(); + var __ = UpdatePeriodic(); + }, null, timeTillNextWholeMinute, TimeSpan.FromMinutes(1)); + } - // Send users a lil status message - // We're not actually properly disconnecting from the gateway (lol) so it'll linger for a few minutes - // Should be plenty of time for the bot to connect again next startup and set the real status - if (_hasReceivedReady) - await _client.UpdateStatusAsync(new DiscordActivity("Restarting... (please wait)"), UserStatus.Idle); - } + private async Task OnEventReceived(int shardId, IGatewayEvent evt) + { + // we HandleGatewayEvent **before** getting the own user, because the own user is set in HandleGatewayEvent for ReadyEvent + await _cache.HandleGatewayEvent(evt); - private Task HandleEvent(DiscordClient shard, T evt) where T: DiscordEventArgs - { - // We don't want to stall the event pipeline, so we'll "fork" inside here - var _ = HandleEventInner(); - return Task.CompletedTask; + var userId = await _cache.GetOwnUser(); + await _cache.TryUpdateSelfMember(userId, evt); - async Task HandleEventInner() + await OnEventReceivedInner(shardId, evt); + } + + private async Task OnEventReceivedInner(int shardId, IGatewayEvent evt) + { + // HandleEvent takes a type parameter, automatically inferred by the event type + // It will then look up an IEventHandler in the DI container and call that object's handler method + // For registering new ones, see Modules.cs + if (evt is MessageCreateEvent mc) + await HandleEvent(shardId, mc); + if (evt is MessageUpdateEvent mu) + await HandleEvent(shardId, mu); + if (evt is MessageDeleteEvent md) + await HandleEvent(shardId, md); + if (evt is MessageDeleteBulkEvent mdb) + await HandleEvent(shardId, mdb); + if (evt is MessageReactionAddEvent mra) + await HandleEvent(shardId, mra); + if (evt is InteractionCreateEvent ic) + await HandleEvent(shardId, ic); + } + + public async Task Shutdown() + { + // This will stop the timer and prevent any subsequent invocations + await _periodicTask.DisposeAsync(); + + // Send users a lil status message + // We're not actually properly disconnecting from the gateway (lol) so it'll linger for a few minutes + // Should be plenty of time for the bot to connect again next startup and set the real status + await Task.WhenAll(_cluster.Shards.Values.Select(shard => + shard.UpdateStatus(new GatewayStatusUpdate + { + Activities = new[] + { + new Activity + { + Name = "Restarting... (please wait)", + Type = ActivityType.Game + } + }, + Status = GatewayStatusUpdate.UserStatus.Idle + }))); + } + + private Task HandleEvent(int shardId, T evt) where T : IGatewayEvent + { + // We don't want to stall the event pipeline, so we'll "fork" inside here + var _ = HandleEventInner(); + return Task.CompletedTask; + + async Task HandleEventInner() + { + await Task.Yield(); + + await using var serviceScope = _services.BeginLifetimeScope(); + + // Find an event handler that can handle the type of event () we're given + IEventHandler handler; + try + { + handler = serviceScope.Resolve>(); + } + catch (Exception e) + { + _logger.Error(e, "Error instantiating handler class"); + return; + } + + using var _ = LogContext.PushProperty("EventId", Guid.NewGuid()); + using var __ = LogContext.Push(await serviceScope.Resolve().GetEnricher(shardId, evt)); + _logger.Verbose("Received gateway event: {@Event}", evt); + + try { - using var _ = LogContext.PushProperty("EventId", Guid.NewGuid()); - _logger - .ForContext("Elastic", "yes?") - .Verbose("Gateway event: {@Event}", evt); - - await using var serviceScope = _services.BeginLifetimeScope(); - - // Also, find a Sentry enricher for the event type (if one is present), and ask it to put some event data in the Sentry scope - var sentryEnricher = serviceScope.ResolveOptional>(); - sentryEnricher?.Enrich(serviceScope.Resolve(), shard, evt); - - // Find an event handler that can handle the type of event () we're given - var handler = serviceScope.Resolve>(); var queue = serviceScope.ResolveOptional>(); - try - { - using var timer = _metrics.Measure.Timer.Time(BotMetrics.EventsHandled, - new MetricTags("event", typeof(T).Name.Replace("EventArgs", ""))); + // Also, find a Sentry enricher for the event type (if one is present), and ask it to put some event data in the Sentry scope + var sentryEnricher = serviceScope.ResolveOptional>(); + sentryEnricher?.Enrich(serviceScope.Resolve(), shardId, evt); - // Delegate to the queue to see if it wants to handle this event - // the TryHandle call returns true if it's handled the event - // Usually it won't, so just pass it on to the main handler - if (queue == null || !await queue.TryHandle(evt)) - await handler.Handle(shard, evt); - } - catch (Exception exc) - { - await HandleError(handler, evt, serviceScope, exc); - } + using var timer = _metrics.Measure.Timer.Time(BotMetrics.EventsHandled, + new MetricTags("event", typeof(T).Name.Replace("Event", ""))); + + // Delegate to the queue to see if it wants to handle this event + // the TryHandle call returns true if it's handled the event + // Usually it won't, so just pass it on to the main handler + if (queue == null || !await queue.TryHandle(evt)) + await handler.Handle(shardId, evt); } - } - - private async Task HandleError(IEventHandler handler, T evt, ILifetimeScope serviceScope, Exception exc) - where T: DiscordEventArgs - { - _metrics.Measure.Meter.Mark(BotMetrics.BotErrors, exc.GetType().FullName); - - // Make this beforehand so we can access the event ID for logging - var sentryEvent = new SentryEvent(exc); - - _logger - .ForContext("Elastic", "yes?") - .Error(exc, "Exception in event handler: {SentryEventId}", sentryEvent.EventId); - - // If the event is us responding to our own error messages, don't bother logging - if (evt is MessageCreateEventArgs mc && mc.Author.Id == _client.CurrentUser.Id) - return; - - var shouldReport = exc.IsOurProblem(); - if (shouldReport) + catch (Exception exc) { - // Report error to Sentry - // This will just no-op if there's no URL set - var sentryScope = serviceScope.Resolve(); - - // Add some specific info about Discord error responses, as a breadcrumb - if (exc is BadRequestException bre) - sentryScope.AddBreadcrumb(bre.WebResponse.Response, "response.error", data: new Dictionary(bre.WebResponse.Headers)); - if (exc is NotFoundException nfe) - sentryScope.AddBreadcrumb(nfe.WebResponse.Response, "response.error", data: new Dictionary(nfe.WebResponse.Headers)); - if (exc is UnauthorizedException ue) - sentryScope.AddBreadcrumb(ue.WebResponse.Response, "response.error", data: new Dictionary(ue.WebResponse.Headers)); - - SentrySdk.CaptureEvent(sentryEvent, sentryScope); - - // Once we've sent it to Sentry, report it to the user (if we have permission to) - var reportChannel = handler.ErrorChannelFor(evt); - if (reportChannel != null && reportChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks)) - await _errorMessageService.SendErrorMessage(reportChannel, sentryEvent.EventId.ToString()); + await HandleError(handler, evt, serviceScope, exc); } } - - private async Task UpdatePeriodic() - { - _logger.Debug("Running once-per-minute scheduled tasks"); - - await UpdateBotStatus(); - - // Clean up message cache in postgres - await _commandMessageService.CleanupOldMessages(); - - // Collect some stats, submit them to the metrics backend - await _collector.CollectStats(); - await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync()); - _logger.Debug("Submitted metrics to backend"); - } - - private async Task UpdateBotStatus(DiscordClient specificShard = null) - { - // If we're not on any shards, don't bother (this happens if the periodic timer fires before the first Ready) - if (!_hasReceivedReady) return; - - var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count); - try // DiscordClient may throw an exception if the socket is closed (e.g just after OP 7 received) - { - Task UpdateStatus(DiscordClient shard) => - shard.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers | shard #{shard.ShardId}")); - - if (specificShard != null) - await UpdateStatus(specificShard); - else // Run shard updates concurrently - await Task.WhenAll(_client.ShardClients.Values.Select(UpdateStatus)); - } - catch (WebSocketException) { } - } + } + + private async Task HandleError(IEventHandler handler, T evt, ILifetimeScope serviceScope, + Exception exc) + where T : IGatewayEvent + { + _metrics.Measure.Meter.Mark(BotMetrics.BotErrors, exc.GetType().FullName); + + var ourUserId = await _cache.GetOwnUser(); + + // Make this beforehand so we can access the event ID for logging + var sentryEvent = new SentryEvent(exc); + + // If the event is us responding to our own error messages, don't bother logging + if (evt is MessageCreateEvent mc && mc.Author.Id == ourUserId) + return; + + var shouldReport = exc.IsOurProblem(); + if (shouldReport) + { + // only log exceptions if they're our problem + _logger.Error(exc, "Exception in event handler: {SentryEventId}", sentryEvent.EventId); + + // Report error to Sentry + // This will just no-op if there's no URL set + var sentryScope = serviceScope.Resolve(); + + // Add some specific info about Discord error responses, as a breadcrumb + // TODO: headers to dict + // if (exc is BadRequestException bre) + // sentryScope.AddBreadcrumb(bre.Response, "response.error", data: new Dictionary(bre.Response.Headers)); + // if (exc is NotFoundException nfe) + // sentryScope.AddBreadcrumb(nfe.Response, "response.error", data: new Dictionary(nfe.Response.Headers)); + // if (exc is UnauthorizedException ue) + // sentryScope.AddBreadcrumb(ue.Response, "response.error", data: new Dictionary(ue.Response.Headers)); + + SentrySdk.CaptureEvent(sentryEvent, sentryScope); + + // most of these errors aren't useful... + if (_config.DisableErrorReporting) + return; + + // Once we've sent it to Sentry, report it to the user (if we have permission to) + var reportChannel = handler.ErrorChannelFor(evt, ourUserId); + if (reportChannel == null) + return; + + var botPerms = await _cache.PermissionsIn(reportChannel.Value); + if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks)) + await _errorMessageService.SendErrorMessage(reportChannel.Value, sentryEvent.EventId.ToString()); + } + } + + private async Task UpdatePeriodic() + { + _logger.Debug("Running once-per-minute scheduled tasks"); + + // Check from a new custom status from Redis and update Discord accordingly + if (_redis.Connection != null && _config.RedisGatewayUrl == null) + { + var newStatus = await _redis.Connection.GetDatabase().StringGetAsync("pluralkit:botstatus"); + if (newStatus != CustomStatusMessage) + { + CustomStatusMessage = newStatus; + + _logger.Information("Pushing new bot status message to Discord"); + await Task.WhenAll(_cluster.Shards.Values.Select(shard => + shard.UpdateStatus(new GatewayStatusUpdate + { + Activities = new[] + { + new Activity + { + Name = BotStatus, + Type = ActivityType.Game + } + }, + Status = GatewayStatusUpdate.UserStatus.Online + }))); + } + } + + // Collect some stats, submit them to the metrics backend + await _collector.CollectStats(); + await Task.WhenAll(((IMetricsRoot)_metrics).ReportRunner.RunAllAsync()); + _logger.Debug("Submitted metrics to backend"); } } \ No newline at end of file diff --git a/PluralKit.Bot/BotConfig.cs b/PluralKit.Bot/BotConfig.cs index 153b965c..8c42ed7f 100644 --- a/PluralKit.Bot/BotConfig.cs +++ b/PluralKit.Bot/BotConfig.cs @@ -1,15 +1,44 @@ -namespace PluralKit.Bot -{ - public class BotConfig - { - public static readonly string[] DefaultPrefixes = {"pk;", "pk!"}; +namespace PluralKit.Bot; - public string Token { get; set; } - public ulong? ClientId { get; set; } - - // ASP.NET configuration merges arrays with defaults, so we leave this field nullable - // and fall back to the separate default array at the use site :) - // This does bind [] as null (therefore default) instead of an empty array, but I can live w/ that. - public string[] Prefixes { get; set; } +public class BotConfig +{ + public static readonly string[] DefaultPrefixes = { "pk;", "pk!" }; + + public string Token { get; set; } + public ulong? ClientId { get; set; } + + // ASP.NET configuration merges arrays with defaults, so we leave this field nullable + // and fall back to the separate default array at the use site :) + // This does bind [] as null (therefore default) instead of an empty array, but I can live w/ that. + public string[] Prefixes { get; set; } + + public int? MaxShardConcurrency { get; set; } + + public ulong? AdminRole { get; set; } + + public ClusterSettings? Cluster { get; set; } + + public string? GatewayQueueUrl { get; set; } + public bool UseRedisRatelimiter { get; set; } = false; + public bool UseRedisCache { get; set; } = false; + + public string? RedisGatewayUrl { get; set; } + + public string? DiscordBaseUrl { get; set; } + + public bool DisableErrorReporting { get; set; } = false; + + public bool IsBetaBot { get; set; } = false!; + public string BetaBotAPIUrl { get; set; } + + public record ClusterSettings + { + // this is zero-indexed + public string NodeName { get; set; } + public int TotalShards { get; set; } + public int TotalNodes { get; set; } + + // Node name eg. "pluralkit-3", want to extract the 3. blame k8s :p + public int NodeIndex => int.Parse(NodeName.Split("-").Last()); } } \ No newline at end of file diff --git a/PluralKit.Bot/BotMetrics.cs b/PluralKit.Bot/BotMetrics.cs index 6d423ffa..42d8b64b 100644 --- a/PluralKit.Bot/BotMetrics.cs +++ b/PluralKit.Bot/BotMetrics.cs @@ -1,29 +1,108 @@ using App.Metrics; -using App.Metrics.Gauge; using App.Metrics.Meter; using App.Metrics.Timer; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public static class BotMetrics { - public static class BotMetrics + public static MeterOptions MessagesReceived => new() { - public static MeterOptions MessagesReceived => new MeterOptions {Name = "Messages processed", MeasurementUnit = Unit.Events, RateUnit = TimeUnit.Seconds, Context = "Bot"}; - public static MeterOptions MessagesProxied => new MeterOptions {Name = "Messages proxied", MeasurementUnit = Unit.Events, RateUnit = TimeUnit.Seconds, Context = "Bot"}; - public static MeterOptions CommandsRun => new MeterOptions {Name = "Commands run", MeasurementUnit = Unit.Commands, RateUnit = TimeUnit.Seconds, Context = "Bot"}; - public static GaugeOptions MembersTotal => new GaugeOptions {Name = "Members total", MeasurementUnit = Unit.None, Context = "Bot"}; - public static GaugeOptions MembersOnline => new GaugeOptions {Name = "Members online", MeasurementUnit = Unit.None, Context = "Bot"}; - public static GaugeOptions Guilds => new GaugeOptions {Name = "Guilds", MeasurementUnit = Unit.None, Context = "Bot"}; - public static GaugeOptions Channels => new GaugeOptions {Name = "Channels", MeasurementUnit = Unit.None, Context = "Bot"}; - public static GaugeOptions ShardLatency => new GaugeOptions { Name = "Shard Latency", Context = "Bot" }; - public static GaugeOptions ShardsConnected => new GaugeOptions { Name = "Shards Connected", Context = "Bot", MeasurementUnit = Unit.Connections }; - public static MeterOptions WebhookCacheMisses => new MeterOptions { Name = "Webhook cache misses", Context = "Bot", MeasurementUnit = Unit.Calls }; - public static GaugeOptions WebhookCacheSize => new GaugeOptions { Name = "Webhook Cache Size", Context = "Bot", MeasurementUnit = Unit.Items }; - public static TimerOptions WebhookResponseTime => new TimerOptions { Name = "Webhook Response Time", Context = "Bot", RateUnit = TimeUnit.Seconds, MeasurementUnit = Unit.Requests, DurationUnit = TimeUnit.Seconds }; - public static TimerOptions MessageContextQueryTime => new TimerOptions { Name = "Message context query duration", Context = "Bot", RateUnit = TimeUnit.Seconds, DurationUnit = TimeUnit.Seconds, MeasurementUnit = Unit.Calls }; - public static TimerOptions ProxyMembersQueryTime => new TimerOptions { Name = "Proxy member query duration", Context = "Bot", RateUnit = TimeUnit.Seconds, DurationUnit = TimeUnit.Seconds, MeasurementUnit = Unit.Calls }; - public static TimerOptions DiscordApiRequests => new TimerOptions { Name = "Discord API requests", MeasurementUnit = Unit.Requests, DurationUnit = TimeUnit.Milliseconds, Context = "Bot"}; - public static MeterOptions BotErrors => new MeterOptions { Name = "Bot errors", MeasurementUnit = Unit.Errors, RateUnit = TimeUnit.Seconds, Context = "Bot"}; - public static MeterOptions ErrorMessagesSent => new MeterOptions { Name = "Error messages sent", MeasurementUnit = Unit.Errors, RateUnit = TimeUnit.Seconds, Context = "Bot"}; - public static TimerOptions EventsHandled => new TimerOptions { Name = "Events handled", MeasurementUnit = Unit.Errors, RateUnit = TimeUnit.Seconds, DurationUnit = TimeUnit.Seconds, Context = "Bot"}; - } + Name = "Messages processed", + MeasurementUnit = Unit.Events, + RateUnit = TimeUnit.Seconds, + Context = "Bot" + }; + + public static MeterOptions MessagesProxied => new() + { + Name = "Messages proxied", + MeasurementUnit = Unit.Events, + RateUnit = TimeUnit.Seconds, + Context = "Bot" + }; + + public static MeterOptions CommandsRun => new() + { + Name = "Commands run", + MeasurementUnit = Unit.Commands, + RateUnit = TimeUnit.Seconds, + Context = "Bot" + }; + + public static TimerOptions CommandTime => new() + { + Name = "Command run time", + MeasurementUnit = Unit.Commands, + RateUnit = TimeUnit.Seconds, + DurationUnit = TimeUnit.Seconds, + Context = "Bot" + }; + + public static MeterOptions WebhookCacheMisses => new() + { + Name = "Webhook cache misses", + Context = "Bot", + MeasurementUnit = Unit.Calls + }; + + public static TimerOptions WebhookResponseTime => new() + { + Name = "Webhook Response Time", + Context = "Bot", + RateUnit = TimeUnit.Seconds, + MeasurementUnit = Unit.Requests, + DurationUnit = TimeUnit.Seconds + }; + + public static TimerOptions MessageContextQueryTime => new() + { + Name = "Message context query duration", + Context = "Bot", + RateUnit = TimeUnit.Seconds, + DurationUnit = TimeUnit.Seconds, + MeasurementUnit = Unit.Calls + }; + + public static TimerOptions ProxyMembersQueryTime => new() + { + Name = "Proxy member query duration", + Context = "Bot", + RateUnit = TimeUnit.Seconds, + DurationUnit = TimeUnit.Seconds, + MeasurementUnit = Unit.Calls + }; + + public static TimerOptions DiscordApiRequests => new() + { + Name = "Discord API requests", + MeasurementUnit = Unit.Requests, + DurationUnit = TimeUnit.Milliseconds, + Context = "Bot" + }; + + public static MeterOptions BotErrors => new() + { + Name = "Bot errors", + MeasurementUnit = Unit.Errors, + RateUnit = TimeUnit.Seconds, + Context = "Bot" + }; + + public static MeterOptions ErrorMessagesSent => new() + { + Name = "Error messages sent", + MeasurementUnit = Unit.Errors, + RateUnit = TimeUnit.Seconds, + Context = "Bot" + }; + + public static TimerOptions EventsHandled => new() + { + Name = "Events handled", + MeasurementUnit = Unit.Errors, + RateUnit = TimeUnit.Seconds, + DurationUnit = TimeUnit.Seconds, + Context = "Bot" + }; } \ No newline at end of file diff --git a/PluralKit.Bot/CommandMeta/CommandHelp.cs b/PluralKit.Bot/CommandMeta/CommandHelp.cs new file mode 100644 index 00000000..08601749 --- /dev/null +++ b/PluralKit.Bot/CommandMeta/CommandHelp.cs @@ -0,0 +1,148 @@ +namespace PluralKit.Bot; + +public partial class CommandTree +{ + public static Command SystemInfo = new Command("system", "system [system]", "Looks up information about a system"); + public static Command SystemNew = new Command("system new", "system new [name]", "Creates a new system"); + public static Command SystemRename = new Command("system name", "system [system] rename [name]", "Renames your system"); + public static Command SystemDesc = new Command("system description", "system [system] description [description]", "Changes your system's description"); + public static Command SystemColor = new Command("system color", "system [system] color [color]", "Changes your system's color"); + public static Command SystemTag = new Command("system tag", "system [system] tag [tag]", "Changes your system's tag"); + public static Command SystemPronouns = new Command("system pronouns", "system [system] pronouns [pronouns]", "Changes your system's pronouns"); + public static Command SystemServerTag = new Command("system servertag", "system [system] servertag [tag|enable|disable]", "Changes your system's tag in the current server"); + public static Command SystemAvatar = new Command("system icon", "system [system] icon [url|@mention]", "Changes your system's icon"); + public static Command SystemBannerImage = new Command("system banner", "system [system] banner [url]", "Set the system's banner image"); + public static Command SystemDelete = new Command("system delete", "system [system] delete", "Deletes your system"); + public static Command SystemProxy = new Command("system proxy", "system proxy [server id] [on|off]", "Enables or disables message proxying in a specific server"); + public static Command SystemList = new Command("system list", "system [system] list [full]", "Lists a system's members"); + public static Command SystemFind = new Command("system find", "system [system] find [full] ", "Searches a system's members given a search term"); + public static Command SystemFronter = new Command("system fronter", "system [system] fronter", "Shows a system's fronter(s)"); + public static Command SystemFrontHistory = new Command("system fronthistory", "system [system] fronthistory", "Shows a system's front history"); + public static Command SystemFrontPercent = new Command("system frontpercent", "system [system] frontpercent [timespan]", "Shows a system's front breakdown"); + public static Command SystemPrivacy = new Command("system privacy", "system [system] privacy ", "Changes your system's privacy settings"); + public static Command ConfigTimezone = new Command("config timezone", "config timezone [timezone]", "Changes your system's time zone"); + public static Command ConfigPing = new Command("config ping", "config ping ", "Changes your system's ping preferences"); + public static Command ConfigAutoproxyAccount = new Command("config autoproxy account", "autoproxy account [on|off]", "Toggles autoproxy globally for the current account"); + public static Command ConfigAutoproxyTimeout = new Command("config autoproxy timeout", "autoproxy timeout [|off|reset]", "Sets the latch timeout duration for your system"); + public static Command ConfigMemberDefaultPrivacy = new("config private member", "config private member [on|off]", "Sets whether member privacy is automatically set to private when creating a new member"); + public static Command ConfigGroupDefaultPrivacy = new("config private group", "config private group [on|off]", "Sets whether group privacy is automatically set to private when creating a new group"); + public static Command AutoproxySet = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for the current server"); + public static Command AutoproxyOff = new Command("autoproxy off", "autoproxy off", "Disables autoproxying for your system in the current server"); + public static Command AutoproxyFront = new Command("autoproxy front", "autoproxy front", "Sets your system's autoproxy in this server to proxy the first member currently registered as front"); + public static Command AutoproxyLatch = new Command("autoproxy latch", "autoproxy latch", "Sets your system's autoproxy in this server to proxy the last manually proxied member"); + public static Command AutoproxyMember = new Command("autoproxy member", "autoproxy ", "Sets your system's autoproxy in this server to proxy a specific member"); + public static Command MemberInfo = new Command("member", "member ", "Looks up information about a member"); + public static Command MemberNew = new Command("member new", "member new ", "Creates a new member"); + public static Command MemberRename = new Command("member rename", "member rename ", "Renames a member"); + public static Command MemberDesc = new Command("member description", "member description [description]", "Changes a member's description"); + public static Command MemberPronouns = new Command("member pronouns", "member pronouns [pronouns]", "Changes a member's pronouns"); + public static Command MemberColor = new Command("member color", "member color [color]", "Changes a member's color"); + public static Command MemberBirthday = new Command("member birthday", "member birthday [birthday]", "Changes a member's birthday"); + public static Command MemberProxy = new Command("member proxy", "member proxy [add|remove] [example proxy]", "Changes, adds, or removes a member's proxy tags"); + public static Command MemberDelete = new Command("member delete", "member delete", "Deletes a member"); + public static Command MemberBannerImage = new Command("member banner", "member banner [url]", "Set the member's banner image"); + public static Command MemberAvatar = new Command("member avatar", "member avatar [url|@mention]", "Changes a member's avatar"); + public static Command MemberGroups = new Command("member group", "member group", "Shows the groups a member is in"); + public static Command MemberGroupAdd = new Command("member group", "member group add [group 2] [group 3...]", "Adds a member to one or more groups"); + public static Command MemberGroupRemove = new Command("member group", "member group remove [group 2] [group 3...]", "Removes a member from one or more groups"); + public static Command MemberServerAvatar = new Command("member serveravatar", "member serveravatar [url|@mention]", "Changes a member's avatar in the current server"); + public static Command MemberDisplayName = new Command("member displayname", "member displayname [display name]", "Changes a member's display name"); + public static Command MemberServerName = new Command("member servername", "member servername [server name]", "Changes a member's display name in the current server"); + public static Command MemberAutoproxy = new Command("member autoproxy", "member autoproxy [on|off]", "Sets whether a member will be autoproxied when autoproxy is set to latch or front mode."); + public static Command MemberKeepProxy = new Command("member keepproxy", "member keepproxy [on|off]", "Sets whether to include a member's proxy tags when proxying"); + public static Command MemberRandom = new Command("random", "random", "Shows the info card of a randomly selected member in your system."); + public static Command MemberPrivacy = new Command("member privacy", "member privacy ", "Changes a members's privacy settings"); + public static Command GroupInfo = new Command("group", "group ", "Looks up information about a group"); + public static Command GroupNew = new Command("group new", "group new ", "Creates a new group"); + public static Command GroupList = new Command("group list", "group list", "Lists all groups in this system"); + public static Command GroupMemberList = new Command("group members", "group list", "Lists all members in a group"); + public static Command GroupRename = new Command("group rename", "group rename ", "Renames a group"); + public static Command GroupDisplayName = new Command("group displayname", "group displayname [display name]", "Changes a group's display name"); + public static Command GroupDesc = new Command("group description", "group description [description]", "Changes a group's description"); + public static Command GroupColor = new Command("group color", "group color [color]", "Changes a group's color"); + public static Command GroupAdd = new Command("group add", "group add [member 2] [member 3...]", "Adds one or more members to a group"); + public static Command GroupRemove = new Command("group remove", "group remove [member 2] [member 3...]", "Removes one or more members from a group"); + public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); + public static Command GroupBannerImage = new Command("group banner", "group banner [url]", "Set the group's banner image"); + public static Command GroupIcon = new Command("group icon", "group icon [url|@mention]", "Changes a group's icon"); + public static Command GroupDelete = new Command("group delete", "group delete", "Deletes a group"); + public static Command GroupFrontPercent = new Command("group frontpercent", "group frontpercent [timespan]", "Shows a group's front breakdown."); + public static Command GroupMemberRandom = new Command("group random", "group random", "Shows the info card of a randomly selected member in a group."); + public static Command GroupRandom = new Command("random", "random group", "Shows the info card of a randomly selected group in your system."); + public static Command Switch = new Command("switch", "switch [member 2] [member 3...]", "Registers a switch"); + public static Command SwitchOut = new Command("switch out", "switch out", "Registers a switch with no members"); + public static Command SwitchMove = new Command("switch move", "switch move ", "Moves the latest switch in time"); + public static Command SwitchEdit = new Command("switch edit", "switch edit [member 2] [member 3...]", "Edits the members in the latest switch"); + public static Command SwitchEditOut = new Command("switch edit out", "switch edit out", "Turns the latest switch into a switch-out"); + public static Command SwitchDelete = new Command("switch delete", "switch delete", "Deletes the latest switch"); + public static Command SwitchDeleteAll = new Command("switch delete", "switch delete all", "Deletes all logged switches"); + public static Command Link = new Command("link", "link ", "Links your system to another account"); + public static Command Unlink = new Command("unlink", "unlink [account]", "Unlinks your system from an account"); + public static Command TokenGet = new Command("token", "token", "Gets your system's API token"); + public static Command TokenRefresh = new Command("token refresh", "token refresh", "Resets your system's API token"); + public static Command Import = new Command("import", "import [fileurl]", "Imports system information from a data file"); + public static Command Export = new Command("export", "export", "Exports system information to a data file"); + public static Command Help = new Command("help", "help", "Shows help information about PluralKit"); + public static Command Explain = new Command("explain", "explain", "Explains the basics of systems and proxying"); + public static Command Message = new Command("message", "message [delete|author]", "Looks up a proxied message"); + public static Command MessageEdit = new Command("edit", "edit [link] ", "Edit a previously proxied message"); + public static Command MessageReproxy = new Command("reproxy", "reproxy [link] ", "Reproxy a previously proxied message using a different member"); + public static Command ProxyCheck = new Command("debug proxy", "debug proxy [link|reply]", "Checks why your message has not been proxied"); + public static Command LogChannel = new Command("log channel", "log channel ", "Designates a channel to post proxied messages to"); + public static Command LogChannelClear = new Command("log channel", "log channel -clear", "Clears the currently set log channel"); + public static Command LogEnable = new Command("log enable", "log enable all| [channel 2] [channel 3...]", "Enables message logging in certain channels"); + public static Command LogDisable = new Command("log disable", "log disable all| [channel 2] [channel 3...]", "Disables message logging in certain channels"); + public static Command LogClean = new Command("logclean", "logclean [on|off]", "Toggles whether to clean up other bots' log channels"); + public static Command BlacklistShow = new Command("blacklist show", "blacklist show", "Displays the current proxy blacklist"); + public static Command BlacklistAdd = new Command("blacklist add", "blacklist add all| [channel 2] [channel 3...]", "Adds certain channels to the proxy blacklist"); + public static Command BlacklistRemove = new Command("blacklist remove", "blacklist remove all| [channel 2] [channel 3...]", "Removes certain channels from the proxy blacklist"); + public static Command Invite = new Command("invite", "invite", "Gets a link to invite PluralKit to other servers"); + public static Command PermCheck = new Command("permcheck", "permcheck ", "Checks whether a server's permission setup is correct"); + public static Command Admin = new Command("admin", "admin", "Super secret admin commands (sshhhh)"); + + public static Command[] SystemCommands = + { + SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemBannerImage, SystemColor, + SystemDelete, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent, SystemPrivacy, SystemProxy + }; + + public static Command[] MemberCommands = + { + MemberInfo, MemberNew, MemberRename, MemberDisplayName, MemberServerName, MemberDesc, MemberPronouns, + MemberColor, MemberBirthday, MemberProxy, MemberAutoproxy, MemberKeepProxy, MemberGroups, MemberGroupAdd, + MemberGroupRemove, MemberDelete, MemberAvatar, MemberServerAvatar, MemberBannerImage, MemberPrivacy, + MemberRandom + }; + + public static Command[] GroupCommands = + { + GroupInfo, GroupList, GroupNew, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, GroupIcon, + GroupBannerImage, GroupColor, GroupPrivacy, GroupDelete + }; + + public static Command[] GroupCommandsTargeted = + { + GroupInfo, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, GroupIcon, GroupPrivacy, + GroupDelete, GroupMemberRandom, GroupFrontPercent + }; + + public static Command[] SwitchCommands = + { + Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, SwitchDelete, SwitchDeleteAll + }; + + public static Command[] ConfigCommands = + { + ConfigTimezone, ConfigPing, ConfigAutoproxyAccount, ConfigAutoproxyTimeout, + ConfigMemberDefaultPrivacy, ConfigGroupDefaultPrivacy + }; + + public static Command[] AutoproxyCommands = + { + AutoproxyOff, AutoproxyFront, AutoproxyLatch, AutoproxyMember + }; + + public static Command[] LogCommands = { LogChannel, LogChannelClear, LogEnable, LogDisable }; + + public static Command[] BlacklistCommands = { BlacklistAdd, BlacklistRemove, BlacklistShow }; +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandMeta/CommandParseErrors.cs b/PluralKit.Bot/CommandMeta/CommandParseErrors.cs new file mode 100644 index 00000000..522aab14 --- /dev/null +++ b/PluralKit.Bot/CommandMeta/CommandParseErrors.cs @@ -0,0 +1,49 @@ +using Humanizer; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public partial class CommandTree +{ + private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands) + { + var commandListStr = CreatePotentialCommandList(potentialCommands); + await ctx.Reply( + $"{Emojis.Error} Unknown command `pk;{ctx.FullCommand().Truncate(100)}`. Perhaps you meant to use one of the following commands?\n{commandListStr}\n\nFor a full list of possible commands, see ."); + } + + private async Task PrintCommandExpectedError(Context ctx, params Command[] potentialCommands) + { + var commandListStr = CreatePotentialCommandList(potentialCommands); + await ctx.Reply( + $"{Emojis.Error} You need to pass a command. Perhaps you meant to use one of the following commands?\n{commandListStr}\n\nFor a full list of possible commands, see ."); + } + + private static string CreatePotentialCommandList(params Command[] potentialCommands) + { + return string.Join("\n", potentialCommands.Select(cmd => $"- **pk;{cmd.Usage}** - *{cmd.Description}*")); + } + + private async Task PrintCommandList(Context ctx, string subject, params Command[] commands) + { + var str = CreatePotentialCommandList(commands); + await ctx.Reply($"Here is a list of commands related to {subject}: \n{str}\nFor a full list of possible commands, see ."); + } + + private async Task CreateSystemNotFoundError(Context ctx) + { + var input = ctx.PopArgument(); + if (input.TryParseMention(out var id)) + { + // Try to resolve the user ID to find the associated account, + // so we can print their username. + var user = await ctx.Rest.GetUser(id); + if (user != null) + return $"Account **{user.Username}#{user.Discriminator}** does not have a system registered."; + return $"Account with ID `{id}` not found."; + } + + return $"System with ID {input.AsCode()} not found."; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs new file mode 100644 index 00000000..29be7bf0 --- /dev/null +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -0,0 +1,505 @@ +using PluralKit.Core; + +namespace PluralKit.Bot; + +public partial class CommandTree +{ + public Task ExecuteCommand(Context ctx) + { + if (ctx.Match("system", "s")) + return HandleSystemCommand(ctx); + if (ctx.Match("member", "m")) + return HandleMemberCommand(ctx); + if (ctx.Match("group", "g")) + return HandleGroupCommand(ctx); + if (ctx.Match("switch", "sw")) + return HandleSwitchCommand(ctx); + if (ctx.Match("commands", "cmd", "c")) + return CommandHelpRoot(ctx); + if (ctx.Match("ap", "autoproxy", "auto")) + return HandleAutoproxyCommand(ctx); + if (ctx.Match("config", "cfg")) + return HandleConfigCommand(ctx); + if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd", "ls")) + return ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); + if (ctx.Match("link")) + return ctx.Execute(Link, m => m.LinkSystem(ctx)); + if (ctx.Match("unlink")) + return ctx.Execute(Unlink, m => m.UnlinkAccount(ctx)); + if (ctx.Match("token")) + if (ctx.Match("refresh", "renew", "invalidate", "reroll", "regen")) + return ctx.Execute(TokenRefresh, m => m.RefreshToken(ctx)); + else + return ctx.Execute(TokenGet, m => m.GetToken(ctx)); + if (ctx.Match("import")) + return ctx.Execute(Import, m => m.Import(ctx)); + if (ctx.Match("export")) + return ctx.Execute(Export, m => m.Export(ctx)); + if (ctx.Match("help", "h")) + if (ctx.Match("commands")) + return ctx.Reply("For the list of commands, see the website: "); + else if (ctx.Match("proxy")) + return ctx.Reply( + "The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"); + else return ctx.Execute(Help, m => m.HelpRoot(ctx)); + if (ctx.Match("explain")) + return ctx.Execute(Explain, m => m.Explain(ctx)); + if (ctx.Match("message", "msg")) + return ctx.Execute(Message, m => m.GetMessage(ctx)); + if (ctx.Match("edit", "e")) + return ctx.Execute(MessageEdit, m => m.EditMessage(ctx)); + if (ctx.Match("reproxy", "rp")) + return ctx.Execute(MessageReproxy, m => m.ReproxyMessage(ctx)); + if (ctx.Match("log")) + if (ctx.Match("channel")) + return ctx.Execute(LogChannel, m => m.SetLogChannel(ctx)); + else if (ctx.Match("enable", "on")) + return ctx.Execute(LogEnable, m => m.SetLogEnabled(ctx, true)); + else if (ctx.Match("disable", "off")) + return ctx.Execute(LogDisable, m => m.SetLogEnabled(ctx, false)); + else if (ctx.Match("commands")) + return PrintCommandList(ctx, "message logging", LogCommands); + else return PrintCommandExpectedError(ctx, LogCommands); + if (ctx.Match("logclean")) + return ctx.Execute(LogClean, m => m.SetLogCleanup(ctx)); + if (ctx.Match("blacklist", "bl")) + if (ctx.Match("enable", "on", "add", "deny")) + return ctx.Execute(BlacklistAdd, m => m.SetBlacklisted(ctx, true)); + else if (ctx.Match("disable", "off", "remove", "allow")) + return ctx.Execute(BlacklistRemove, m => m.SetBlacklisted(ctx, false)); + else if (ctx.Match("list", "show")) + return ctx.Execute(BlacklistShow, m => m.ShowBlacklisted(ctx)); + else if (ctx.Match("commands")) + return PrintCommandList(ctx, "channel blacklisting", BlacklistCommands); + else return PrintCommandExpectedError(ctx, BlacklistCommands); + if (ctx.Match("proxy")) + if (ctx.Match("debug")) + return ctx.Execute(ProxyCheck, m => m.MessageProxyCheck(ctx)); + else + return ctx.Execute(SystemProxy, m => m.SystemProxy(ctx)); + if (ctx.Match("invite")) return ctx.Execute(Invite, m => m.Invite(ctx)); + if (ctx.Match("mn")) return ctx.Execute(null, m => m.Mn(ctx)); + if (ctx.Match("fire")) return ctx.Execute(null, m => m.Fire(ctx)); + if (ctx.Match("thunder")) return ctx.Execute(null, m => m.Thunder(ctx)); + if (ctx.Match("freeze")) return ctx.Execute(null, m => m.Freeze(ctx)); + if (ctx.Match("starstorm")) return ctx.Execute(null, m => m.Starstorm(ctx)); + if (ctx.Match("flash")) return ctx.Execute(null, m => m.Flash(ctx)); + if (ctx.Match("rool")) return ctx.Execute(null, m => m.Rool(ctx)); + if (ctx.Match("sus")) return ctx.Execute(null, m => m.Sus(ctx)); + if (ctx.Match("error")) return ctx.Execute(null, m => m.Error(ctx)); + if (ctx.Match("stats")) return ctx.Execute(null, m => m.Stats(ctx)); + if (ctx.Match("permcheck")) + return ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx)); + if (ctx.Match("proxycheck")) + return ctx.Execute(ProxyCheck, m => m.MessageProxyCheck(ctx)); + if (ctx.Match("debug")) + return HandleDebugCommand(ctx); + if (ctx.Match("admin")) + return HandleAdminCommand(ctx); + if (ctx.Match("random", "r")) + if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g")) + return ctx.Execute(GroupRandom, r => r.Group(ctx)); + else + return ctx.Execute(MemberRandom, m => m.Member(ctx)); + + // remove compiler warning + return ctx.Reply( + $"{Emojis.Error} Unknown command {ctx.PeekArgument().AsCode()}. For a list of possible commands, see ."); + } + + private async Task HandleAdminCommand(Context ctx) + { + if (ctx.Match("usid", "updatesystemid")) + await ctx.Execute(Admin, a => a.UpdateSystemId(ctx)); + else if (ctx.Match("umid", "updatememberid")) + await ctx.Execute(Admin, a => a.UpdateMemberId(ctx)); + else if (ctx.Match("ugid", "updategroupid")) + await ctx.Execute(Admin, a => a.UpdateGroupId(ctx)); + else if (ctx.Match("uml", "updatememberlimit")) + await ctx.Execute(Admin, a => a.SystemMemberLimit(ctx)); + else if (ctx.Match("ugl", "updategrouplimit")) + await ctx.Execute(Admin, a => a.SystemGroupLimit(ctx)); + else + await ctx.Reply($"{Emojis.Error} Unknown command."); + } + + private async Task HandleDebugCommand(Context ctx) + { + var availableCommandsStr = "Available debug targets: `permissions`, `proxying`"; + + if (ctx.Match("permissions", "perms", "permcheck")) + if (ctx.Match("channel", "ch")) + await ctx.Execute(PermCheck, m => m.PermCheckChannel(ctx)); + else + await ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx)); + else if (ctx.Match("channel")) + await ctx.Execute(PermCheck, m => m.PermCheckChannel(ctx)); + else if (ctx.Match("proxy", "proxying", "proxycheck")) + await ctx.Execute(ProxyCheck, m => m.MessageProxyCheck(ctx)); + else if (!ctx.HasNext()) + await ctx.Reply($"{Emojis.Error} You need to pass a command. {availableCommandsStr}"); + else + await ctx.Reply( + $"{Emojis.Error} Unknown debug command {ctx.PeekArgument().AsCode()}. {availableCommandsStr}"); + } + + private async Task HandleSystemCommand(Context ctx) + { + // these commands never take a system target + if (ctx.Match("new", "create", "make", "add", "register", "init", "n")) + await ctx.Execute(SystemNew, m => m.New(ctx)); + else if (ctx.Match("commands", "help")) + await PrintCommandList(ctx, "systems", SystemCommands); + + // these are deprecated (and not accessible by other users anyway), let's leave them out of new parsing + else if (ctx.Match("timezone", "tz")) + await ctx.Execute(ConfigTimezone, m => m.SystemTimezone(ctx), true); + else if (ctx.Match("ping")) + await ctx.Execute(ConfigPing, m => m.SystemPing(ctx), true); + + // todo: these aren't deprecated but also shouldn't be here + else if (ctx.Match("webhook", "hook")) + await ctx.Execute(null, m => m.SystemWebhook(ctx)); + else if (ctx.Match("proxy")) + await ctx.Execute(SystemProxy, m => m.SystemProxy(ctx)); + + // finally, parse commands that *can* take a system target + else + { + // try matching a system ID + var target = await ctx.MatchSystem(); + var previousPtr = ctx.Parameters._ptr; + + // if we have a parsed target and no more commands, don't bother with the command flow + // we skip the `target != null` check here since the argument isn't be popped if it's not a system + if (!ctx.HasNext()) + { + await ctx.Execute(SystemInfo, m => m.Query(ctx, target ?? ctx.System)); + return; + } + + // hacky, but we need to CheckSystem(target) which throws a PKError + try + { + await HandleSystemCommandTargeted(ctx, target ?? ctx.System); + } + catch (PKError e) + { + await ctx.Reply($"{Emojis.Error} {e.Message}"); + return; + } + + // if we *still* haven't matched anything, the user entered an invalid command name or system reference + if (ctx.Parameters._ptr == previousPtr) + { + if (ctx.Parameters.Peek().Length != 5 && !ctx.Parameters.Peek().TryParseMention(out _)) + { + await PrintCommandNotFoundError(ctx, SystemCommands); + return; + } + + var list = CreatePotentialCommandList(SystemCommands); + await ctx.Reply($"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\n" + + $"Perhaps you meant to use one of the following commands?\n{list}"); + } + } + } + + private async Task HandleSystemCommandTargeted(Context ctx, PKSystem target) + { + if (ctx.Match("name", "rename", "changename", "rn")) + await ctx.CheckSystem(target).Execute(SystemRename, m => m.Name(ctx, target)); + else if (ctx.Match("tag", "t")) + await ctx.CheckSystem(target).Execute(SystemTag, m => m.Tag(ctx, target)); + else if (ctx.Match("servertag", "st")) + await ctx.CheckSystem(target).Execute(SystemServerTag, m => m.ServerTag(ctx, target)); + else if (ctx.Match("description", "desc", "bio")) + await ctx.CheckSystem(target).Execute(SystemDesc, m => m.Description(ctx, target)); + else if (ctx.Match("pronouns", "prns")) + await ctx.CheckSystem(target).Execute(SystemPronouns, m => m.Pronouns(ctx, target)); + else if (ctx.Match("color", "colour")) + await ctx.CheckSystem(target).Execute(SystemColor, m => m.Color(ctx, target)); + else if (ctx.Match("banner", "splash", "cover")) + await ctx.CheckSystem(target).Execute(SystemBannerImage, m => m.BannerImage(ctx, target)); + else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) + await ctx.CheckSystem(target).Execute(SystemAvatar, m => m.Avatar(ctx, target)); + else if (ctx.Match("list", "l", "members", "ls")) + await ctx.CheckSystem(target).Execute(SystemList, m => m.MemberList(ctx, target)); + else if (ctx.Match("find", "search", "query", "fd", "s")) + await ctx.CheckSystem(target).Execute(SystemFind, m => m.MemberList(ctx, target)); + else if (ctx.Match("f", "front", "fronter", "fronters")) + { + if (ctx.Match("h", "history")) + await ctx.CheckSystem(target).Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target)); + else if (ctx.Match("p", "percent", "%")) + await ctx.CheckSystem(target).Execute(SystemFrontPercent, m => m.FrontPercent(ctx, system: target)); + else + await ctx.CheckSystem(target).Execute(SystemFronter, m => m.SystemFronter(ctx, target)); + } + else if (ctx.Match("fh", "fronthistory", "history", "switches")) + await ctx.CheckSystem(target).Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target)); + else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) + await ctx.CheckSystem(target).Execute(SystemFrontPercent, m => m.FrontPercent(ctx, system: target)); + else if (ctx.Match("info", "view", "show")) + await ctx.CheckSystem(target).Execute(SystemInfo, m => m.Query(ctx, target)); + else if (ctx.Match("groups", "gs")) + await ctx.CheckSystem(target).Execute(GroupList, g => g.ListSystemGroups(ctx, target)); + else if (ctx.Match("privacy")) + await ctx.CheckSystem(target).Execute(SystemPrivacy, m => m.SystemPrivacy(ctx, target)); + else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) + await ctx.CheckSystem(target).Execute(SystemDelete, m => m.Delete(ctx, target)); + } + + private async Task HandleMemberCommand(Context ctx) + { + if (ctx.Match("new", "n", "add", "create", "register")) + await ctx.Execute(MemberNew, m => m.NewMember(ctx)); + else if (ctx.Match("list")) + await ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); + else if (ctx.Match("commands", "help")) + await PrintCommandList(ctx, "members", MemberCommands); + else if (await ctx.MatchMember() is PKMember target) + await HandleMemberCommandTargeted(ctx, target); + else if (!ctx.HasNext()) + await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName, + MemberServerName, MemberDesc, MemberPronouns, + MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar); + else + await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Member", ctx.PopArgument())}"); + } + + private async Task HandleMemberCommandTargeted(Context ctx, PKMember target) + { + // Commands that have a member target (eg. pk;member delete) + if (ctx.Match("rename", "name", "changename", "setname", "rn")) + await ctx.Execute(MemberRename, m => m.Name(ctx, target)); + else if (ctx.Match("description", "info", "bio", "text", "desc")) + await ctx.Execute(MemberDesc, m => m.Description(ctx, target)); + else if (ctx.Match("pronouns", "pronoun", "prns", "pn")) + await ctx.Execute(MemberPronouns, m => m.Pronouns(ctx, target)); + else if (ctx.Match("color", "colour")) + await ctx.Execute(MemberColor, m => m.Color(ctx, target)); + else if (ctx.Match("birthday", "bday", "birthdate", "cakeday", "bdate")) + await ctx.Execute(MemberBirthday, m => m.Birthday(ctx, target)); + else if (ctx.Match("proxy", "tags", "proxytags", "brackets")) + await ctx.Execute(MemberProxy, m => m.Proxy(ctx, target)); + else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) + await ctx.Execute(MemberDelete, m => m.Delete(ctx, target)); + else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic")) + await ctx.Execute(MemberAvatar, m => m.Avatar(ctx, target)); + else if (ctx.Match("banner", "splash", "cover")) + await ctx.Execute(MemberBannerImage, m => m.BannerImage(ctx, target)); + else if (ctx.Match("group", "groups")) + if (ctx.Match("add", "a")) + await ctx.Execute(MemberGroupAdd, + m => m.AddRemoveGroups(ctx, target, Groups.AddRemoveOperation.Add)); + else if (ctx.Match("remove", "rem")) + await ctx.Execute(MemberGroupRemove, + m => m.AddRemoveGroups(ctx, target, Groups.AddRemoveOperation.Remove)); + else + await ctx.Execute(MemberGroups, m => m.ListMemberGroups(ctx, target)); + else if (ctx.Match("serveravatar", "sa", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic", + "guildavatar", "guildpic", "guildicon", "sicon")) + await ctx.Execute(MemberServerAvatar, m => m.ServerAvatar(ctx, target)); + else if (ctx.Match("displayname", "dn", "dname", "nick", "nickname", "dispname")) + await ctx.Execute(MemberDisplayName, m => m.DisplayName(ctx, target)); + else if (ctx.Match("servername", "sn", "sname", "snick", "snickname", "servernick", "servernickname", + "serverdisplayname", "guildname", "guildnick", "guildnickname", "serverdn")) + await ctx.Execute(MemberServerName, m => m.ServerName(ctx, target)); + else if (ctx.Match("autoproxy", "ap")) + await ctx.Execute(MemberAutoproxy, m => m.MemberAutoproxy(ctx, target)); + else if (ctx.Match("keepproxy", "keeptags", "showtags", "kp")) + await ctx.Execute(MemberKeepProxy, m => m.KeepProxy(ctx, target)); + else if (ctx.Match("privacy")) + await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, null)); + else if (ctx.Match("private", "hidden", "hide")) + await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private)); + else if (ctx.Match("public", "shown", "show")) + await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public)); + else if (ctx.Match("soulscream")) + await ctx.Execute(MemberInfo, m => m.Soulscream(ctx, target)); + else if (!ctx.HasNext()) // Bare command + await ctx.Execute(MemberInfo, m => m.ViewMember(ctx, target)); + else + await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberServerName, + MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar, + SystemList); + } + + private async Task HandleGroupCommand(Context ctx) + { + // Commands with no group argument + if (ctx.Match("n", "new")) + await ctx.Execute(GroupNew, g => g.CreateGroup(ctx)); + else if (ctx.Match("list", "l")) + await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); + else if (ctx.Match("commands", "help")) + await PrintCommandList(ctx, "groups", GroupCommands); + else if (await ctx.MatchGroup() is { } target) + { + // Commands with group argument + if (ctx.Match("rename", "name", "changename", "setname", "rn")) + await ctx.Execute(GroupRename, g => g.RenameGroup(ctx, target)); + else if (ctx.Match("nick", "dn", "displayname", "nickname")) + await ctx.Execute(GroupDisplayName, g => g.GroupDisplayName(ctx, target)); + else if (ctx.Match("description", "info", "bio", "text", "desc")) + await ctx.Execute(GroupDesc, g => g.GroupDescription(ctx, target)); + else if (ctx.Match("add", "a")) + await ctx.Execute(GroupAdd, + g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add)); + else if (ctx.Match("remove", "rem", "r")) + await ctx.Execute(GroupRemove, + g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove)); + else if (ctx.Match("members", "list", "ms", "l", "ls")) + await ctx.Execute(GroupMemberList, g => g.ListGroupMembers(ctx, target)); + else if (ctx.Match("random")) + await ctx.Execute(GroupMemberRandom, r => r.GroupMember(ctx, target)); + else if (ctx.Match("privacy")) + await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null)); + else if (ctx.Match("public", "pub")) + await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public)); + else if (ctx.Match("private", "priv")) + await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private)); + else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) + await ctx.Execute(GroupDelete, g => g.DeleteGroup(ctx, target)); + else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) + await ctx.Execute(GroupIcon, g => g.GroupIcon(ctx, target)); + else if (ctx.Match("banner", "splash", "cover")) + await ctx.Execute(GroupBannerImage, g => g.GroupBannerImage(ctx, target)); + else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) + await ctx.Execute(GroupFrontPercent, g => g.FrontPercent(ctx, group: target)); + else if (ctx.Match("color", "colour")) + await ctx.Execute(GroupColor, g => g.GroupColor(ctx, target)); + else if (!ctx.HasNext()) + await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); + else + await PrintCommandNotFoundError(ctx, GroupCommandsTargeted); + } + else if (!ctx.HasNext()) + await PrintCommandExpectedError(ctx, GroupCommands); + else + await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Group", ctx.PopArgument())}"); + } + + private async Task HandleSwitchCommand(Context ctx) + { + if (ctx.Match("out")) + await ctx.Execute(SwitchOut, m => m.SwitchOut(ctx)); + else if (ctx.Match("move", "shift", "offset")) + await ctx.Execute(SwitchMove, m => m.SwitchMove(ctx)); + else if (ctx.Match("edit", "replace")) + if (ctx.Match("out")) + await ctx.Execute(SwitchEditOut, m => m.SwitchEditOut(ctx)); + else + await ctx.Execute(SwitchEdit, m => m.SwitchEdit(ctx)); + else if (ctx.Match("delete", "remove", "erase", "cancel", "yeet")) + await ctx.Execute(SwitchDelete, m => m.SwitchDelete(ctx)); + else if (ctx.Match("commands", "help")) + await PrintCommandList(ctx, "switching", SwitchCommands); + else if (ctx.HasNext()) // there are following arguments + await ctx.Execute(Switch, m => m.SwitchDo(ctx)); + else + await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, + SwitchDelete, SystemFronter, SystemFrontHistory); + } + + private async Task CommandHelpRoot(Context ctx) + { + if (!ctx.HasNext()) + { + await ctx.Reply( + "Available command help targets: `system`, `member`, `group`, `switch`, `config`, `autoproxy`, `log`, `blacklist`." + + "\n- **pk;commands ** - *View commands related to a help target.*" + + "\n\nFor the full list of commands, see the website: "); + return; + } + + switch (ctx.PeekArgument()) + { + case "system": + case "systems": + case "s": + await PrintCommandList(ctx, "systems", SystemCommands); + break; + case "member": + case "members": + case "m": + await PrintCommandList(ctx, "members", MemberCommands); + break; + case "group": + case "groups": + case "g": + await PrintCommandList(ctx, "groups", GroupCommands); + break; + case "switch": + case "switches": + case "switching": + case "sw": + await PrintCommandList(ctx, "switching", SwitchCommands); + break; + case "log": + await PrintCommandList(ctx, "message logging", LogCommands); + break; + case "blacklist": + case "bl": + await PrintCommandList(ctx, "channel blacklisting", BlacklistCommands); + break; + case "config": + case "cfg": + await PrintCommandList(ctx, "settings", ConfigCommands); + break; + case "autoproxy": + case "ap": + await PrintCommandList(ctx, "autoproxy", AutoproxyCommands); + break; + default: + await ctx.Reply("For the full list of commands, see the website: "); + break; + } + } + + private Task HandleAutoproxyCommand(Context ctx) + { + // ctx.CheckSystem(); + // oops, that breaks stuff! PKErrors before ctx.Execute don't actually do anything. + // so we just emulate checking and throwing an error. + if (ctx.System == null) + return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError.Message}"); + + // todo: move this whole block to Autoproxy.cs when these are removed + + if (ctx.Match("account", "ac")) + return ctx.Execute(ConfigAutoproxyAccount, m => m.AutoproxyAccount(ctx), true); + if (ctx.Match("timeout", "tm")) + return ctx.Execute(ConfigAutoproxyTimeout, m => m.AutoproxyTimeout(ctx), true); + + return ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx)); + } + + private Task HandleConfigCommand(Context ctx) + { + if (ctx.System == null) + return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError.Message}"); + + if (!ctx.HasNext()) + return ctx.Execute(null, m => m.ShowConfig(ctx)); + + if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "account", "ac" })) + return ctx.Execute(null, m => m.AutoproxyAccount(ctx)); + if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "timeout", "tm" })) + return ctx.Execute(null, m => m.AutoproxyTimeout(ctx)); + if (ctx.Match("timezone", "zone", "tz")) + return ctx.Execute(null, m => m.SystemTimezone(ctx)); + if (ctx.Match("ping")) + return ctx.Execute(null, m => m.SystemPing(ctx)); + if (ctx.MatchMultiple(new[] { "private" }, new[] { "member" }) || ctx.Match("mp")) + return ctx.Execute(null, m => m.MemberDefaultPrivacy(ctx)); + if (ctx.MatchMultiple(new[] { "private" }, new[] { "group" }) || ctx.Match("gp")) + return ctx.Execute(null, m => m.GroupDefaultPrivacy(ctx)); + if (ctx.MatchMultiple(new[] { "show" }, new[] { "private" }) || ctx.Match("sp")) + return ctx.Execute(null, m => m.ShowPrivateInfo(ctx)); + + // todo: maybe add the list of configuration keys here? + return ctx.Reply($"{Emojis.Error} Could not find a setting with that name. Please see `pk;commands config` for the list of possible config settings."); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Command.cs b/PluralKit.Bot/CommandSystem/Command.cs index 30e6596d..ff6abdcd 100644 --- a/PluralKit.Bot/CommandSystem/Command.cs +++ b/PluralKit.Bot/CommandSystem/Command.cs @@ -1,16 +1,15 @@ -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Command { - public class Command + public Command(string key, string usage, string description) { - public string Key { get; } - public string Usage { get; } - public string Description { get; } - - public Command(string key, string usage, string description) - { - Key = key; - Usage = usage; - Description = description; - } + Key = key; + Usage = usage; + Description = description; } + + public string Key { get; } + public string Usage { get; } + public string Description { get; } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/CommandGroup.cs b/PluralKit.Bot/CommandSystem/CommandGroup.cs index 62f3a060..5ba1693b 100644 --- a/PluralKit.Bot/CommandSystem/CommandGroup.cs +++ b/PluralKit.Bot/CommandSystem/CommandGroup.cs @@ -1,19 +1,16 @@ -using System.Collections.Generic; +namespace PluralKit.Bot; -namespace PluralKit.Bot +public class CommandGroup { - public class CommandGroup + public CommandGroup(string key, string description, ICollection children) { - public string Key { get; } - public string Description { get; } - - public ICollection Children { get; } - - public CommandGroup(string key, string description, ICollection children) - { - Key = key; - Description = description; - Children = children; - } + Key = key; + Description = description; + Children = children; } + + public string Key { get; } + public string Description { get; } + + public ICollection Children { get; } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs deleted file mode 100644 index effdfe46..00000000 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -using App.Metrics; - -using Autofac; - -using DSharpPlus; -using DSharpPlus.Entities; - -using PluralKit.Core; - -namespace PluralKit.Bot -{ - public class Context - { - private readonly ILifetimeScope _provider; - - private readonly DiscordRestClient _rest; - private readonly DiscordShardedClient _client; - private readonly DiscordClient _shard; - private readonly DiscordMessage _message; - private readonly Parameters _parameters; - private readonly MessageContext _messageContext; - - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly PKSystem _senderSystem; - private readonly IMetrics _metrics; - private readonly CommandMessageService _commandMessageService; - - private Command _currentCommand; - - public Context(ILifetimeScope provider, DiscordClient shard, DiscordMessage message, int commandParseOffset, - PKSystem senderSystem, MessageContext messageContext) - { - _rest = provider.Resolve(); - _client = provider.Resolve(); - _message = message; - _shard = shard; - _senderSystem = senderSystem; - _messageContext = messageContext; - _db = provider.Resolve(); - _repo = provider.Resolve(); - _metrics = provider.Resolve(); - _provider = provider; - _commandMessageService = provider.Resolve(); - _parameters = new Parameters(message.Content.Substring(commandParseOffset)); - } - - public DiscordUser Author => _message.Author; - public DiscordChannel Channel => _message.Channel; - public DiscordMessage Message => _message; - public DiscordGuild Guild => _message.Channel.Guild; - public DiscordClient Shard => _shard; - public DiscordShardedClient Client => _client; - public MessageContext MessageContext => _messageContext; - - public DiscordRestClient Rest => _rest; - - public PKSystem System => _senderSystem; - - public Parameters Parameters => _parameters; - - internal IDatabase Database => _db; - internal ModelRepository Repository => _repo; - - public async Task Reply(string text = null, DiscordEmbed embed = null, IEnumerable mentions = null) - { - if (!this.BotHasAllPermissions(Permissions.SendMessages)) - // Will be "swallowed" during the error handler anyway, this message is never shown. - throw new PKError("PluralKit does not have permission to send messages in this channel."); - - if (embed != null && !this.BotHasAllPermissions(Permissions.EmbedLinks)) - throw new PKError("PluralKit does not have permission to send embeds in this channel. Please ensure I have the **Embed Links** permission enabled."); - var msg = await Channel.SendMessageFixedAsync(text, embed: embed, mentions: mentions); - - if (embed != null) - { - // Sensitive information that might want to be deleted by :x: reaction is typically in an embed format (member cards, for example) - // This may need to be changed at some point but works well enough for now - await _commandMessageService.RegisterMessage(msg.Id, Author.Id); - } - - return msg; - } - - public async Task Execute(Command commandDef, Func handler) - { - _currentCommand = commandDef; - - try - { - await handler(_provider.Resolve()); - _metrics.Measure.Meter.Mark(BotMetrics.CommandsRun); - } - catch (PKSyntaxError e) - { - await Reply($"{Emojis.Error} {e.Message}\n**Command usage:**\n> pk;{commandDef.Usage}"); - } - catch (PKError e) - { - await Reply($"{Emojis.Error} {e.Message}"); - } - catch (TimeoutException) - { - // Got a complaint the old error was a bit too patronizing. Hopefully this is better? - await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?"); - } - } - - public LookupContext LookupContextFor(PKSystem target) => - System?.Id == target.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner; - - public LookupContext LookupContextFor(SystemId systemId) => - System?.Id == systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner; - - public LookupContext LookupContextFor(PKMember target) => - System?.Id == target.System ? LookupContext.ByOwner : LookupContext.ByNonOwner; - - public IComponentContext Services => _provider; - } -} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/Context.cs b/PluralKit.Bot/CommandSystem/Context/Context.cs new file mode 100644 index 00000000..d1233467 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/Context.cs @@ -0,0 +1,170 @@ +using System; +using System.Threading.Tasks; + +using App.Metrics; + +using Autofac; + +using NodaTime; + +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class Context +{ + private readonly ILifetimeScope _provider; + + private readonly IMetrics _metrics; + private readonly CommandMessageService _commandMessageService; + + private Command? _currentCommand; + + public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, + PKSystem senderSystem, SystemConfig config, MessageContext messageContext) + { + Message = (Message)message; + ShardId = shardId; + Guild = guild; + Channel = channel; + System = senderSystem; + Config = config; + MessageContext = messageContext; + Cache = provider.Resolve(); + Database = provider.Resolve(); + Repository = provider.Resolve(); + _metrics = provider.Resolve(); + _provider = provider; + _commandMessageService = provider.Resolve(); + CommandPrefix = message.Content?.Substring(0, commandParseOffset); + Parameters = new Parameters(message.Content?.Substring(commandParseOffset)); + Rest = provider.Resolve(); + Cluster = provider.Resolve(); + } + + public readonly IDiscordCache Cache; + public readonly DiscordApiClient Rest; + + public readonly Channel Channel; + public User Author => Message.Author; + public GuildMemberPartial Member => ((MessageCreateEvent)Message).Member; + + public readonly Message Message; + public readonly Guild Guild; + public readonly int ShardId; + public readonly Cluster Cluster; + public readonly MessageContext MessageContext; + + public Task BotPermissions => Cache.PermissionsIn(Channel.Id); + public Task UserPermissions => Cache.PermissionsFor((MessageCreateEvent)Message); + + + public readonly PKSystem System; + public readonly SystemConfig Config; + public DateTimeZone Zone => Config?.Zone ?? DateTimeZone.Utc; + + public readonly string CommandPrefix; + public readonly Parameters Parameters; + + internal readonly IDatabase Database; + internal readonly ModelRepository Repository; + + public async Task Reply(string text = null, Embed embed = null, AllowedMentions? mentions = null) + { + var botPerms = await BotPermissions; + + if (!botPerms.HasFlag(PermissionSet.SendMessages)) + // Will be "swallowed" during the error handler anyway, this message is never shown. + throw new PKError("PluralKit does not have permission to send messages in this channel."); + + if (embed != null && !botPerms.HasFlag(PermissionSet.EmbedLinks)) + throw new PKError("PluralKit does not have permission to send embeds in this channel. Please ensure I have the **Embed Links** permission enabled."); + + var msg = await Rest.CreateMessage(Channel.Id, new MessageRequest + { + Content = text, + Embeds = embed != null ? new[] { embed } : null, + // Default to an empty allowed mentions object instead of null (which means no mentions allowed) + AllowedMentions = mentions ?? new AllowedMentions() + }); + + if (embed != null) + { + // Sensitive information that might want to be deleted by :x: reaction is typically in an embed format (member cards, for example) + // This may need to be changed at some point but works well enough for now + await _commandMessageService.RegisterMessage(msg.Id, msg.ChannelId, Author.Id); + } + + return msg; + } + + public async Task Execute(Command? commandDef, Func handler, bool deprecated = false) + { + _currentCommand = commandDef; + + try + { + using (_metrics.Measure.Timer.Time(BotMetrics.CommandTime, new MetricTags("Command", commandDef?.Key ?? "null"))) + await handler(_provider.Resolve()); + + _metrics.Measure.Meter.Mark(BotMetrics.CommandsRun); + } + catch (PKSyntaxError e) + { + await Reply($"{Emojis.Error} {e.Message}\n**Command usage:**\n> pk;{commandDef?.Usage}"); + } + catch (PKError e) + { + await Reply($"{Emojis.Error} {e.Message}"); + } + catch (TimeoutException) + { + // Got a complaint the old error was a bit too patronizing. Hopefully this is better? + await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?"); + } + + if (deprecated && commandDef != null) + await Reply($"{Emojis.Warn} This command is deprecated and will be removed soon. In the future, please use `pk;{commandDef.Key}`."); + } + + /// + /// Same as LookupContextFor, but skips flags / config checks. + /// + public LookupContext DirectLookupContextFor(SystemId systemId) + => System?.Id == systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner; + + public LookupContext LookupContextFor(SystemId systemId) + { + var hasPrivateOverride = this.MatchFlag("private", "priv"); + var hasPublicOverride = this.MatchFlag("public", "pub"); + + if (hasPrivateOverride && hasPublicOverride) + throw new PKError("Cannot match both public and private flags at the same time."); + + if (System?.Id != systemId) + { + if (hasPrivateOverride) + throw Errors.NotOwnInfo; + return LookupContext.ByNonOwner; + } + + if (hasPrivateOverride) + return LookupContext.ByOwner; + if (hasPublicOverride) + return LookupContext.ByNonOwner; + + return Config.ShowPrivateInfo + ? LookupContext.ByOwner + : LookupContext.ByNonOwner; + } + + public IComponentContext Services => _provider; +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs new file mode 100644 index 00000000..90c3bb39 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs @@ -0,0 +1,189 @@ +using System.Text.RegularExpressions; + +using Myriad.Types; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public static class ContextArgumentsExt +{ + public static string PopArgument(this Context ctx) => + ctx.Parameters.Pop(); + + public static string PeekArgument(this Context ctx) => + ctx.Parameters.Peek(); + + public static string RemainderOrNull(this Context ctx, bool skipFlags = true) => + ctx.Parameters.Remainder(skipFlags).Length == 0 ? null : ctx.Parameters.Remainder(skipFlags); + + public static bool HasNext(this Context ctx, bool skipFlags = true) => + ctx.RemainderOrNull(skipFlags) != null; + + public static string FullCommand(this Context ctx) => + ctx.Parameters.FullCommand; + + /// + /// Checks if the next parameter is equal to one of the given keywords and pops it from the stack. Case-insensitive. + /// + public static bool Match(this Context ctx, ref string used, params string[] potentialMatches) + { + var arg = ctx.PeekArgument(); + foreach (var match in potentialMatches) + if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase)) + { + used = ctx.PopArgument(); + return true; + } + + return false; + } + + /// + /// Checks if the next parameter is equal to one of the given keywords. Case-insensitive. + /// + public static bool Match(this Context ctx, params string[] potentialMatches) + { + string used = null; // Unused and unreturned, we just yeet it + return ctx.Match(ref used, potentialMatches); + } + + /// + /// Checks if the next parameter (starting from `ptr`) is equal to one of the given keywords, and leaves it on the stack. Case-insensitive. + /// + public static bool PeekMatch(this Context ctx, ref int ptr, string[] potentialMatches) + { + var arg = ctx.Parameters.PeekWithPtr(ref ptr); + foreach (var match in potentialMatches) + if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase)) + return true; + + return false; + } + + /// + /// Matches the next *n* parameters against each parameter consecutively. + ///
+ /// Note that this is handled differently than single-parameter Match: + /// each method parameter is an array of potential matches for the *n*th command string parameter. + ///
+ public static bool MatchMultiple(this Context ctx, params string[][] potentialParametersMatches) + { + int ptr = ctx.Parameters._ptr; + + foreach (var param in potentialParametersMatches) + if (!ctx.PeekMatch(ref ptr, param)) return false; + + ctx.Parameters._ptr = ptr; + + return true; + } + + public static bool MatchFlag(this Context ctx, params string[] potentialMatches) + { + // Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here. + // Can assume the caller array only contains lowercase *and* the set below only contains lowercase + + var flags = ctx.Parameters.Flags(); + return potentialMatches.Any(potentialMatch => flags.Contains(potentialMatch)); + } + + public static async Task MatchClear(this Context ctx, string toClear = null) + { + var matched = ctx.MatchClearInner(); + if (matched && toClear != null) + return await ctx.ConfirmClear(toClear); + return matched; + } + + private static bool MatchClearInner(this Context ctx) + => ctx.Match("clear", "reset", "default") || ctx.MatchFlag("c", "clear"); + + public static bool MatchRaw(this Context ctx) => + ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw"); + + public static bool MatchToggle(this Context ctx, bool? defaultValue = null) + { + if (defaultValue != null && ctx.MatchClearInner()) + return defaultValue.Value; + + var yesToggles = new[] { "yes", "on", "enable", "enabled", "true" }; + var noToggles = new[] { "no", "off", "disable", "disabled", "false" }; + + if (ctx.Match(yesToggles) || ctx.MatchFlag(yesToggles)) + return true; + else if (ctx.Match(noToggles) || ctx.MatchFlag(noToggles)) + return false; + else + throw new PKError("You must pass either \"on\" or \"off\" to this command."); + } + + public static (ulong? messageId, ulong? channelId) MatchMessage(this Context ctx, bool parseRawMessageId) + { + if (ctx.Message.Type == Message.MessageType.Reply && ctx.Message.MessageReference?.MessageId != null) + return (ctx.Message.MessageReference.MessageId, ctx.Message.MessageReference.ChannelId); + + var word = ctx.PeekArgument(); + if (word == null) + return (null, null); + + if (parseRawMessageId && ulong.TryParse(word, out var mid)) + return (mid, null); + + var match = Regex.Match(word, "https://(?:\\w+.)?discord(?:app)?.com/channels/\\d+/(\\d+)/(\\d+)"); + if (!match.Success) + return (null, null); + + var channelId = ulong.Parse(match.Groups[1].Value); + var messageId = ulong.Parse(match.Groups[2].Value); + ctx.PopArgument(); + return (messageId, channelId); + } + + public static async Task> ParseMemberList(this Context ctx, SystemId? restrictToSystem) + { + var members = new List(); + + // Loop through all the given arguments + while (ctx.HasNext()) + { + // and attempt to match a member + var member = await ctx.MatchMember(restrictToSystem); + + if (member == null) + // if we can't, big error. Every member name must be valid. + throw new PKError(ctx.CreateNotFoundError("Member", ctx.PopArgument())); + + members.Add(member); // Then add to the final output list + } + + if (members.Count == 0) throw new PKSyntaxError("You must input at least one member."); + + return members; + } + + public static async Task> ParseGroupList(this Context ctx, SystemId? restrictToSystem) + { + var groups = new List(); + + // Loop through all the given arguments + while (ctx.HasNext()) + { + // and attempt to match a group + var group = await ctx.MatchGroup(restrictToSystem); + if (group == null) + // if we can't, big error. Every group name must be valid. + throw new PKError(ctx.CreateNotFoundError("Group", ctx.PopArgument())); + + // todo: remove this, the database query enforces the restriction + if (restrictToSystem != null && group.System != restrictToSystem) + throw Errors.NotOwnGroupError; // TODO: name *which* group? + + groups.Add(group); // Then add to the final output list + } + + if (groups.Count == 0) throw new PKSyntaxError("You must input at least one group."); + + return groups; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs new file mode 100644 index 00000000..aad38c23 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs @@ -0,0 +1,61 @@ +#nullable enable +using Myriad.Extensions; +using Myriad.Types; + +namespace PluralKit.Bot; + +public static class ContextAvatarExt +{ + public static async Task MatchImage(this Context ctx) + { + // If we have a user @mention/ID, use their avatar + if (await ctx.MatchUser() is { } user) + { + var url = user.AvatarUrl("png", 256); + return new ParsedImage { Url = url, Source = AvatarSource.User, SourceUser = user }; + } + + // If we have a positional argument, try to parse it as a URL + var arg = ctx.RemainderOrNull(); + if (arg != null) + { + // Allow surrounding the URL with to "de-embed" + if (arg.StartsWith("<") && arg.EndsWith(">")) + arg = arg.Substring(1, arg.Length - 2); + + if (!Uri.TryCreate(arg, UriKind.Absolute, out var uri)) + throw Errors.InvalidUrl(arg); + + if (uri.Scheme != "http" && uri.Scheme != "https") + throw Errors.InvalidUrl(arg); + + // ToString URL-decodes, which breaks URLs to spaces; AbsoluteUri doesn't + return new ParsedImage { Url = uri.AbsoluteUri, Source = AvatarSource.Url }; + } + + // If we have an attachment, use that + if (ctx.Message.Attachments.FirstOrDefault() is { } attachment) + { + var url = attachment.ProxyUrl; + return new ParsedImage { Url = url, Source = AvatarSource.Attachment }; + } + + // We should only get here if there are no arguments (which would get parsed as URL + throw if error) + // and if there are no attachments (which would have been caught just before) + return null; + } +} + +public struct ParsedImage +{ + public string Url; + public AvatarSource Source; + public User? SourceUser; +} + +public enum AvatarSource +{ + Url, + User, + Attachment +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextChecksExt.cs new file mode 100644 index 00000000..7dc1f757 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/ContextChecksExt.cs @@ -0,0 +1,121 @@ +using Autofac; + +using Myriad.Extensions; +using Myriad.Types; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public static class ContextChecksExt +{ + public static Context CheckGuildContext(this Context ctx) + { + if (ctx.Channel.GuildId != null) return ctx; + throw new PKError("This command can not be run in a DM."); + } + + public static Context CheckDMContext(this Context ctx) + { + if (ctx.Channel.GuildId == null) return ctx; + throw new PKError("This command must be run in a DM."); + } + + public static Context CheckSystemPrivacy(this Context ctx, SystemId target, PrivacyLevel level) + { + if (level.CanAccess(ctx.DirectLookupContextFor(target))) return ctx; + throw Errors.LookupNotAllowed; + } + + public static Context CheckOwnSystem(this Context ctx, PKSystem system) + { + if (system.Id != ctx.System?.Id) + throw Errors.NotOwnSystemError; + return ctx; + } + + public static Context CheckOwnMember(this Context ctx, PKMember member) + { + if (member.System != ctx.System?.Id) + throw Errors.NotOwnMemberError; + return ctx; + } + + public static Context CheckOwnGroup(this Context ctx, PKGroup group) + { + if (group.System != ctx.System?.Id) + throw Errors.NotOwnGroupError; + return ctx; + } + + public static Context CheckSystem(this Context ctx) + { + if (ctx.System == null) + throw Errors.NoSystemError; + return ctx; + } + + public static Context CheckSystem(this Context ctx, PKSystem system) + { + if (system == null) + throw Errors.NoSystemError; + return ctx; + } + + public static Context CheckNoSystem(this Context ctx) + { + if (ctx.System != null) + throw Errors.ExistingSystemError; + return ctx; + } + + public static async Task CheckAuthorPermission(this Context ctx, PermissionSet neededPerms, + string permissionName) + { + if ((await ctx.UserPermissions & neededPerms) != neededPerms) + throw new PKError( + $"You must have the \"{permissionName}\" permission in this server to use this command."); + return ctx; + } + + public static async Task CheckPermissionsInGuildChannel(this Context ctx, Channel channel, + PermissionSet neededPerms) + { + // this is a quick hack, should probably do it properly eventually + var guild = await ctx.Cache.TryGetGuild(channel.GuildId.Value); + if (guild == null) + guild = await ctx.Rest.GetGuild(channel.GuildId.Value); + if (guild == null) + return false; + + var guildMember = ctx.Member; + + if (ctx.Guild?.Id != channel.GuildId) + { + guildMember = await ctx.Rest.GetGuildMember(channel.GuildId.Value, ctx.Author.Id); + if (guildMember == null) + return false; + } + + var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, guildMember); + if ((userPermissions & neededPerms) == 0) + return false; + + return true; + } + + public static bool CheckBotAdmin(this Context ctx) + { + var botConfig = ctx.Services.Resolve(); + return botConfig.AdminRole != null && ctx.Member != null && + ctx.Member.Roles.Contains(botConfig.AdminRole.Value); + } + + public static Context AssertBotAdmin(this Context ctx) + { + if (!ctx.CheckBotAdmin()) + throw new PKError("This command is only usable by bot admins."); + + return ctx; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs new file mode 100644 index 00000000..2a79b7d2 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs @@ -0,0 +1,209 @@ +using System.Text.RegularExpressions; + +using Myriad.Extensions; +using Myriad.Types; + +using PluralKit.Bot.Utils; +using PluralKit.Core; + +namespace PluralKit.Bot; + +public static class ContextEntityArgumentsExt +{ + public static async Task MatchUser(this Context ctx) + { + var text = ctx.PeekArgument(); + if (text.TryParseMention(out var id)) + return await ctx.Cache.GetOrFetchUser(ctx.Rest, id); + + return null; + } + + public static bool MatchUserRaw(this Context ctx, out ulong id) + { + id = 0; + + var text = ctx.PeekArgument(); + if (text.TryParseMention(out var mentionId)) + id = mentionId; + + return id != 0; + } + + public static Task PeekSystem(this Context ctx) => ctx.MatchSystemInner(); + + public static async Task MatchSystem(this Context ctx) + { + var system = await ctx.MatchSystemInner(); + if (system != null) ctx.PopArgument(); + return system; + } + + private static async Task MatchSystemInner(this Context ctx) + { + var input = ctx.PeekArgument(); + + // System references can take three forms: + // - The direct user ID of an account connected to the system + // - A @mention of an account connected to the system (<@uid>) + // - A system hid + + // Direct IDs and mentions are both handled by the below method: + if (input.TryParseMention(out var id)) + return await ctx.Repository.GetSystemByAccount(id); + + // Finally, try HID parsing + var system = await ctx.Repository.GetSystemByHid(input); + return system; + } + + public static async Task PeekMember(this Context ctx, SystemId? restrictToSystem = null) + { + var input = ctx.PeekArgument(); + + // Member references can have one of three forms, depending on + // whether you're in a system or not: + // - A member hid + // - A textual name of a member *in your own system* + // - a textual display name of a member *in your own system* + + // Skip name / display name matching if the user does not have a system + // or if they specifically request by-HID matching + if (ctx.System != null && !ctx.MatchFlag("id", "by-id")) + { + // First, try finding by member name in system + if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName) + return memberByName; + + // And if that fails, we try finding a member with a display name matching the argument from the system + if (ctx.System != null && + await ctx.Repository.GetMemberByDisplayName(ctx.System.Id, input) is PKMember memberByDisplayName) + return memberByDisplayName; + } + + // Finally (or if by-HID lookup is specified), check if input is a valid HID and then try member HID parsing: + + if (!Regex.IsMatch(input, @"^[a-zA-Z]{5}$")) + return null; + + // For posterity: + // There was a bug that made `SELECT * FROM MEMBERS WHERE HID = $1` hang forever BUT + // `SELECT * FROM MEMBERS WHERE HID = $1 AND SYSTEM = $2` *doesn't* hang! So this is a bandaid for that + + // If we are supposed to restrict it to a system anyway we can just do that + PKMember memberByHid = null; + if (restrictToSystem != null) + { + memberByHid = await ctx.Repository.GetMemberByHid(input, restrictToSystem); + if (memberByHid != null) + return memberByHid; + } + // otherwise we try the querier's system and if that doesn't work we do global + else + { + memberByHid = await ctx.Repository.GetMemberByHid(input, ctx.System?.Id); + if (memberByHid != null) + return memberByHid; + + // ff ctx.System was null then this would be a duplicate of above and we don't want to run it again + if (ctx.System != null) + { + memberByHid = await ctx.Repository.GetMemberByHid(input); + if (memberByHid != null) + return memberByHid; + } + } + + // We didn't find anything, so we return null. + return null; + } + + /// + /// Attempts to pop a member descriptor from the stack, returning it if present. If a member could not be + /// resolved by the next word in the argument stack, does *not* touch the stack, and returns null. + /// + public static async Task MatchMember(this Context ctx, SystemId? restrictToSystem = null) + { + // First, peek a member + var member = await ctx.PeekMember(restrictToSystem); + + // If the peek was successful, we've used up the next argument, so we pop that just to get rid of it. + if (member != null) ctx.PopArgument(); + + // Finally, we return the member value. + return member; + } + + public static async Task PeekGroup(this Context ctx, SystemId? restrictToSystem = null) + { + var input = ctx.PeekArgument(); + + // see PeekMember for an explanation of the logic used here + + if (ctx.System != null && !ctx.MatchFlag("id", "by-id")) + { + if (await ctx.Repository.GetGroupByName(ctx.System.Id, input) is { } byName) + return byName; + if (await ctx.Repository.GetGroupByDisplayName(ctx.System.Id, input) is { } byDisplayName) + return byDisplayName; + } + + if (await ctx.Repository.GetGroupByHid(input, restrictToSystem) is { } byHid) + return byHid; + + return null; + } + + public static async Task MatchGroup(this Context ctx, SystemId? restrictToSystem = null) + { + var group = await ctx.PeekGroup(restrictToSystem); + if (group != null) ctx.PopArgument(); + return group; + } + + public static string CreateNotFoundError(this Context ctx, string entity, string input) + { + var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id"); + + if (isIDOnlyQuery) + { + if (input.Length == 5) + return $"{entity} with ID \"{input}\" not found."; + return $"{entity} not found. Note that a {entity.ToLower()} ID is 5 characters long."; + } + + if (input.Length == 5) + return $"{entity} with ID or name \"{input}\" not found."; + return $"{entity} with name \"{input}\" not found. Note that a {entity.ToLower()} ID is 5 characters long."; + } + + public static async Task MatchChannel(this Context ctx) + { + if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id)) + return null; + + var channel = await ctx.Cache.TryGetChannel(id); + if (channel == null) + channel = await ctx.Rest.GetChannelOrNull(id); + if (channel == null) + return null; + + if (!DiscordUtils.IsValidGuildChannel(channel)) + return null; + + ctx.PopArgument(); + return channel; + } + + public static async Task MatchGuild(this Context ctx) + { + if (!ulong.TryParse(ctx.PeekArgument(), out var id)) + return null; + + var guild = await ctx.Rest.GetGuildOrNull(id); + if (guild != null) + ctx.PopArgument(); + + return guild; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs new file mode 100644 index 00000000..ed7b2621 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs @@ -0,0 +1,51 @@ +using PluralKit.Core; + +namespace PluralKit.Bot; + +public static class ContextPrivacyExt +{ + public static PrivacyLevel PopPrivacyLevel(this Context ctx) + { + if (ctx.Match("public", "show", "shown", "visible")) + return PrivacyLevel.Public; + + if (ctx.Match("private", "hide", "hidden")) + return PrivacyLevel.Private; + + if (!ctx.HasNext()) + throw new PKSyntaxError("You must pass a privacy level (`public` or `private`)"); + + throw new PKSyntaxError( + $"Invalid privacy level {ctx.PopArgument().AsCode()} (must be `public` or `private`)."); + } + + public static SystemPrivacySubject PopSystemPrivacySubject(this Context ctx) + { + if (!SystemPrivacyUtils.TryParseSystemPrivacy(ctx.PeekArgument(), out var subject)) + throw new PKSyntaxError( + $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `members`, `front`, `fronthistory`, `groups`, or `all`)."); + + ctx.PopArgument(); + return subject; + } + + public static MemberPrivacySubject PopMemberPrivacySubject(this Context ctx) + { + if (!MemberPrivacyUtils.TryParseMemberPrivacy(ctx.PeekArgument(), out var subject)) + throw new PKSyntaxError( + $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `avatar`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`)."); + + ctx.PopArgument(); + return subject; + } + + public static GroupPrivacySubject PopGroupPrivacySubject(this Context ctx) + { + if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject)) + throw new PKSyntaxError( + $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `icon`, `metadata`, `visibility`, or `all`)."); + + ctx.PopArgument(); + return subject; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs deleted file mode 100644 index 3e1b2572..00000000 --- a/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using PluralKit.Core; - -namespace PluralKit.Bot -{ - public static class ContextArgumentsExt - { - public static string PopArgument(this Context ctx) => - ctx.Parameters.Pop(); - - public static string PeekArgument(this Context ctx) => - ctx.Parameters.Peek(); - - public static string RemainderOrNull(this Context ctx, bool skipFlags = true) => - ctx.Parameters.Remainder(skipFlags).Length == 0 ? null : ctx.Parameters.Remainder(skipFlags); - - public static bool HasNext(this Context ctx, bool skipFlags = true) => - ctx.RemainderOrNull(skipFlags) != null; - - public static string FullCommand(this Context ctx) => - ctx.Parameters.FullCommand; - - /// - /// Checks if the next parameter is equal to one of the given keywords. Case-insensitive. - /// - public static bool Match(this Context ctx, ref string used, params string[] potentialMatches) - { - var arg = ctx.PeekArgument(); - foreach (var match in potentialMatches) - { - if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase)) - { - used = ctx.PopArgument(); - return true; - } - } - - return false; - } - - /// - /// Checks if the next parameter is equal to one of the given keywords. Case-insensitive. - /// - public static bool Match(this Context ctx, params string[] potentialMatches) - { - string used = null; // Unused and unreturned, we just yeet it - return ctx.Match(ref used, potentialMatches); - } - - public static bool MatchFlag(this Context ctx, params string[] potentialMatches) - { - // Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here. - // Can assume the caller array only contains lowercase *and* the set below only contains lowercase - - var flags = ctx.Parameters.Flags(); - return potentialMatches.Any(potentialMatch => flags.Contains(potentialMatch)); - } - - public static async Task MatchClear(this Context ctx, string toClear = null) - { - var matched = ctx.Match("clear", "reset") || ctx.MatchFlag("c", "clear"); - if (matched && toClear != null) - return await ctx.ConfirmClear(toClear); - return matched; - } - - public static async Task> ParseMemberList(this Context ctx, SystemId? restrictToSystem) - { - var members = new List(); - - // Loop through all the given arguments - while (ctx.HasNext()) - { - // and attempt to match a member - var member = await ctx.MatchMember(); - if (member == null) - // if we can't, big error. Every member name must be valid. - throw new PKError(ctx.CreateMemberNotFoundError(ctx.PopArgument())); - - if (restrictToSystem != null && member.System != restrictToSystem) - throw Errors.NotOwnMemberError; // TODO: name *which* member? - - members.Add(member); // Then add to the final output list - } - if (members.Count == 0) throw new PKSyntaxError($"You must input at least one member."); - - return members; - } - - public static async Task> ParseGroupList(this Context ctx, SystemId? restrictToSystem) - { - var groups = new List(); - - // Loop through all the given arguments - while (ctx.HasNext()) - { - // and attempt to match a group - var group = await ctx.MatchGroup(); - if (group == null) - // if we can't, big error. Every group name must be valid. - throw new PKError(ctx.CreateGroupNotFoundError(ctx.PopArgument())); - - if (restrictToSystem != null && group.System != restrictToSystem) - throw Errors.NotOwnGroupError; // TODO: name *which* group? - - groups.Add(group); // Then add to the final output list - } - - if (groups.Count == 0) throw new PKSyntaxError($"You must input at least one group."); - - return groups; - } - } -} diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs deleted file mode 100644 index 5ae896bb..00000000 --- a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs +++ /dev/null @@ -1,59 +0,0 @@ -using DSharpPlus; - -using PluralKit.Core; - -namespace PluralKit.Bot -{ - public static class ContextChecksExt - { - public static Context CheckGuildContext(this Context ctx) - { - if (ctx.Channel.Guild != null) return ctx; - throw new PKError("This command can not be run in a DM."); - } - - public static Context CheckSystemPrivacy(this Context ctx, PKSystem target, PrivacyLevel level) - { - if (level.CanAccess(ctx.LookupContextFor(target))) return ctx; - throw new PKError("You do not have permission to access this information."); - } - - public static Context CheckOwnMember(this Context ctx, PKMember member) - { - if (member.System != ctx.System?.Id) - throw Errors.NotOwnMemberError; - return ctx; - } - - public static Context CheckOwnGroup(this Context ctx, PKGroup group) - { - if (group.System != ctx.System?.Id) - throw Errors.NotOwnGroupError; - return ctx; - } - - public static Context CheckSystem(this Context ctx) - { - if (ctx.System == null) - throw Errors.NoSystemError; - return ctx; - } - - public static Context CheckNoSystem(this Context ctx) - { - if (ctx.System != null) - throw Errors.ExistingSystemError; - return ctx; - } - - public static Context CheckAuthorPermission(this Context ctx, Permissions neededPerms, string permissionName) - { - // TODO: can we always assume Author is a DiscordMember? I would think so, given they always come from a - // message received event... - var hasPerms = ctx.Channel.PermissionsInSync(ctx.Author); - if ((hasPerms & neededPerms) != neededPerms) - throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command."); - return ctx; - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs deleted file mode 100644 index 32dd11c0..00000000 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Threading.Tasks; - -using DSharpPlus; -using DSharpPlus.Entities; - -using PluralKit.Bot.Utils; -using PluralKit.Core; - -namespace PluralKit.Bot -{ - public static class ContextEntityArgumentsExt - { - public static async Task MatchUser(this Context ctx) - { - var text = ctx.PeekArgument(); - if (text.TryParseMention(out var id)) - return await ctx.Shard.GetUser(id); - return null; - } - - public static bool MatchUserRaw(this Context ctx, out ulong id) - { - id = 0; - - var text = ctx.PeekArgument(); - if (text.TryParseMention(out var mentionId)) - id = mentionId; - - return id != 0; - } - - public static Task PeekSystem(this Context ctx) => ctx.MatchSystemInner(); - - public static async Task MatchSystem(this Context ctx) - { - var system = await ctx.MatchSystemInner(); - if (system != null) ctx.PopArgument(); - return system; - } - - private static async Task MatchSystemInner(this Context ctx) - { - var input = ctx.PeekArgument(); - - // System references can take three forms: - // - The direct user ID of an account connected to the system - // - A @mention of an account connected to the system (<@uid>) - // - A system hid - - await using var conn = await ctx.Database.Obtain(); - - // Direct IDs and mentions are both handled by the below method: - if (input.TryParseMention(out var id)) - return await ctx.Repository.GetSystemByAccount(conn, id); - - // Finally, try HID parsing - var system = await ctx.Repository.GetSystemByHid(conn, input); - return system; - } - - public static async Task PeekMember(this Context ctx) - { - var input = ctx.PeekArgument(); - - // Member references can have one of three forms, depending on - // whether you're in a system or not: - // - A member hid - // - A textual name of a member *in your own system* - // - a textual display name of a member *in your own system* - - // First, if we have a system, try finding by member name in system - await using var conn = await ctx.Database.Obtain(); - if (ctx.System != null && await ctx.Repository.GetMemberByName(conn, ctx.System.Id, input) is PKMember memberByName) - return memberByName; - - // Then, try member HID parsing: - if (await ctx.Repository.GetMemberByHid(conn, input) is PKMember memberByHid) - return memberByHid; - - // And if that again fails, we try finding a member with a display name matching the argument from the system - if (ctx.System != null && await ctx.Repository.GetMemberByDisplayName(conn, ctx.System.Id, input) is PKMember memberByDisplayName) - return memberByDisplayName; - - // We didn't find anything, so we return null. - return null; - } - - /// - /// Attempts to pop a member descriptor from the stack, returning it if present. If a member could not be - /// resolved by the next word in the argument stack, does *not* touch the stack, and returns null. - /// - public static async Task MatchMember(this Context ctx) - { - // First, peek a member - var member = await ctx.PeekMember(); - - // If the peek was successful, we've used up the next argument, so we pop that just to get rid of it. - if (member != null) ctx.PopArgument(); - - // Finally, we return the member value. - return member; - } - - public static async Task PeekGroup(this Context ctx) - { - var input = ctx.PeekArgument(); - - await using var conn = await ctx.Database.Obtain(); - if (ctx.System != null && await ctx.Repository.GetGroupByName(conn, ctx.System.Id, input) is {} byName) - return byName; - if (await ctx.Repository.GetGroupByHid(conn, input) is {} byHid) - return byHid; - if (await ctx.Repository.GetGroupByDisplayName(conn, ctx.System.Id, input) is {} byDisplayName) - return byDisplayName; - - return null; - } - - public static async Task MatchGroup(this Context ctx) - { - var group = await ctx.PeekGroup(); - if (group != null) ctx.PopArgument(); - return group; - } - - public static string CreateMemberNotFoundError(this Context ctx, string input) - { - // TODO: does this belong here? - if (input.Length == 5) - { - if (ctx.System != null) - return $"Member with ID or name \"{input}\" not found."; - return $"Member with ID \"{input}\" not found."; // Accounts without systems can't query by name - } - - if (ctx.System != null) - return $"Member with name \"{input}\" not found. Note that a member ID is 5 characters long."; - return $"Member not found. Note that a member ID is 5 characters long."; - } - - public static string CreateGroupNotFoundError(this Context ctx, string input) - { - // TODO: does this belong here? - if (input.Length == 5) - { - if (ctx.System != null) - return $"Group with ID or name \"{input}\" not found."; - return $"Group with ID \"{input}\" not found."; // Accounts without systems can't query by name - } - - if (ctx.System != null) - return $"Group with name \"{input}\" not found. Note that a group ID is 5 characters long."; - return $"Group not found. Note that a group ID is 5 characters long."; - } - - public static async Task MatchChannel(this Context ctx) - { - if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id)) - return null; - - var channel = await ctx.Shard.GetChannel(id); - if (channel == null || !(channel.Type == ChannelType.Text || channel.Type == ChannelType.News)) return null; - - ctx.PopArgument(); - return channel; - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Parameters.cs b/PluralKit.Bot/CommandSystem/Parameters.cs index 8dc229af..e4251e82 100644 --- a/PluralKit.Bot/CommandSystem/Parameters.cs +++ b/PluralKit.Bot/CommandSystem/Parameters.cs @@ -1,187 +1,185 @@ -using System; -using System.Collections.Generic; +namespace PluralKit.Bot; -namespace PluralKit.Bot +public class Parameters { - public class Parameters + // Dictionary of (left, right) quote pairs + // Each char in the string is an individual quote, multi-char strings imply "one of the following chars" + private static readonly Dictionary _quotePairs = new() { - // Dictionary of (left, right) quote pairs - // Each char in the string is an individual quote, multi-char strings imply "one of the following chars" - private static readonly Dictionary _quotePairs = new Dictionary + // Basic + { "'", "'" }, // ASCII single quotes + { "\"", "\"" }, // ASCII double quotes + + // "Smart quotes" + // Specifically ignore the left/right status of the quotes and match any combination of them + // Left string also includes "low" quotes to allow for the low-high style used in some locales + { "\u201C\u201D\u201F\u201E", "\u201C\u201D\u201F" }, // double quotes + { "\u2018\u2019\u201B\u201A", "\u2018\u2019\u201B" }, // single quotes + + // Chevrons (normal and "fullwidth" variants) + { "\u00AB\u300A", "\u00BB\u300B" }, // double chevrons, pointing away (<>) + { "\u00BB\u300B", "\u00AA\u300A" }, // double chevrons, pointing together (>>text<<) + { "\u2039\u3008", "\u203A\u3009" }, // single chevrons, pointing away () + { "\u203A\u3009", "\u2039\u3008" }, // single chevrons, pointing together (>text<) + + // Other + { "\u300C\u300E", "\u300D\u300F" } // corner brackets (Japanese/Chinese) + }; + + private ISet _flags; // Only parsed when requested first time + public int _ptr; + + public string FullCommand { get; } + + private struct WordPosition + { + // Start of the word + internal readonly int startPos; + + // End of the word + internal readonly int endPos; + + // How much to advance word pointer afterwards to point at the start of the *next* word + internal readonly int advanceAfterWord; + + internal readonly bool wasQuoted; + + public WordPosition(int startPos, int endPos, int advanceAfterWord, bool wasQuoted) { - // Basic - {"'", "'"}, // ASCII single quotes - {"\"", "\""}, // ASCII double quotes - - // "Smart quotes" - // Specifically ignore the left/right status of the quotes and match any combination of them - // Left string also includes "low" quotes to allow for the low-high style used in some locales - {"\u201C\u201D\u201F\u201E", "\u201C\u201D\u201F"}, // double quotes - {"\u2018\u2019\u201B\u201A", "\u2018\u2019\u201B"}, // single quotes - - // Chevrons (normal and "fullwidth" variants) - {"\u00AB\u300A", "\u00BB\u300B"}, // double chevrons, pointing away (<>) - {"\u00BB\u300B", "\u00AA\u300A"}, // double chevrons, pointing together (>>text<<) - {"\u2039\u3008", "\u203A\u3009"}, // single chevrons, pointing away () - {"\u203A\u3009", "\u2039\u3008"}, // single chevrons, pointing together (>text<) - - // Other - {"\u300C\u300E", "\u300D\u300F"}, // corner brackets (Japanese/Chinese) - }; - - private readonly string _cmd; - private int _ptr; - private ISet _flags = null; // Only parsed when requested first time - - private struct WordPosition - { - // Start of the word - internal readonly int startPos; - - // End of the word - internal readonly int endPos; - - // How much to advance word pointer afterwards to point at the start of the *next* word - internal readonly int advanceAfterWord; - - internal readonly bool wasQuoted; - - public WordPosition(int startPos, int endPos, int advanceAfterWord, bool wasQuoted) - { - this.startPos = startPos; - this.endPos = endPos; - this.advanceAfterWord = advanceAfterWord; - this.wasQuoted = wasQuoted; - } - } - - public Parameters(string cmd) - { - // This is a SUPER dirty hack to avoid having to match both spaces and newlines in the word detection below - // Instead, we just add a space before every newline (which then gets stripped out later). - _cmd = cmd.Replace("\n", " \n"); - _ptr = 0; - } - - private void ParseFlags() - { - _flags = new HashSet(); - - var ptr = 0; - while (NextWordPosition(ptr) is { } wp) - { - ptr = wp.endPos + wp.advanceAfterWord; - - // Is this word a *flag* (as in, starts with a - AND is not quoted) - if (_cmd[wp.startPos] != '-' || wp.wasQuoted) continue; // (if not, carry on w/ next word) - - // Find the *end* of the flag start (technically allowing arbitrary amounts of dashes) - var flagNameStart = wp.startPos; - while (flagNameStart < _cmd.Length && _cmd[flagNameStart] == '-') - flagNameStart++; - - // Then add the word to the flag set - var word = _cmd.Substring(flagNameStart, wp.endPos - flagNameStart).Trim(); - if (word.Length > 0) - _flags.Add(word.ToLowerInvariant()); - } - } - - public string Pop() - { - // Loop to ignore and skip past flags - while (NextWordPosition(_ptr) is { } pos) - { - _ptr = pos.endPos + pos.advanceAfterWord; - if (_cmd[pos.startPos] == '-' && !pos.wasQuoted) continue; - return _cmd.Substring(pos.startPos, pos.endPos - pos.startPos).Trim(); - } - - return ""; - } - - public string Peek() - { - // Loop to ignore and skip past flags, temp ptr so we don't move the real ptr - var ptr = _ptr; - while (NextWordPosition(ptr) is { } pos) - { - ptr = pos.endPos + pos.advanceAfterWord; - if (_cmd[pos.startPos] == '-' && !pos.wasQuoted) continue; - return _cmd.Substring(pos.startPos, pos.endPos - pos.startPos).Trim(); - } - - return ""; - } - - public ISet Flags() - { - if (_flags == null) ParseFlags(); - return _flags; - } - - public string Remainder(bool skipFlags = true) - { - if (skipFlags) - { - // Skip all *leading* flags when taking the remainder - while (NextWordPosition(_ptr) is {} wp) - { - if (_cmd[wp.startPos] != '-' || wp.wasQuoted) break; - _ptr = wp.endPos + wp.advanceAfterWord; - } - } - - // *Then* get the remainder - return _cmd.Substring(Math.Min(_ptr, _cmd.Length)).Trim(); - } - - public string FullCommand => _cmd; - - private WordPosition? NextWordPosition(int position) - { - // Skip leading spaces before actual content - while (position < _cmd.Length && _cmd[position] == ' ') position++; - - // Is this the end of the string? - if (_cmd.Length <= position) return null; - - // Is this a quoted word? - if (TryCheckQuote(_cmd[position], out var endQuotes)) - { - // We found a quoted word - find an instance of one of the corresponding end quotes - var endQuotePosition = -1; - for (var i = position + 1; i < _cmd.Length; i++) - if (endQuotePosition == -1 && endQuotes.Contains(_cmd[i])) - endQuotePosition = i; // need a break; don't feel like brackets tho lol - - // Position after the end quote should be EOL or a space - // Otherwise we fallthrough to the unquoted word handler below - if (_cmd.Length == endQuotePosition + 1 || _cmd[endQuotePosition + 1] == ' ') - return new WordPosition(position + 1, endQuotePosition, 2, true); - } - - // Not a quoted word, just find the next space and return if it's the end of the command - var wordEnd = _cmd.IndexOf(' ', position + 1); - - return wordEnd == -1 - ? new WordPosition(position, _cmd.Length, 0, false) - : new WordPosition(position, wordEnd, 1, false); - } - - private bool TryCheckQuote(char potentialLeftQuote, out string correspondingRightQuotes) - { - foreach (var (left, right) in _quotePairs) - { - if (left.Contains(potentialLeftQuote)) - { - correspondingRightQuotes = right; - return true; - } - } - - correspondingRightQuotes = null; - return false; + this.startPos = startPos; + this.endPos = endPos; + this.advanceAfterWord = advanceAfterWord; + this.wasQuoted = wasQuoted; } } + + public Parameters(string cmd) + { + // This is a SUPER dirty hack to avoid having to match both spaces and newlines in the word detection below + // Instead, we just add a space before every newline (which then gets stripped out later). + FullCommand = cmd.Replace("\n", " \n"); + _ptr = 0; + } + + private void ParseFlags() + { + _flags = new HashSet(); + + var ptr = 0; + while (NextWordPosition(ptr) is { } wp) + { + ptr = wp.endPos + wp.advanceAfterWord; + + // Is this word a *flag* (as in, starts with a - AND is not quoted) + if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) continue; // (if not, carry on w/ next word) + + // Find the *end* of the flag start (technically allowing arbitrary amounts of dashes) + var flagNameStart = wp.startPos; + while (flagNameStart < FullCommand.Length && FullCommand[flagNameStart] == '-') + flagNameStart++; + + // Then add the word to the flag set + var word = FullCommand.Substring(flagNameStart, wp.endPos - flagNameStart).Trim(); + if (word.Length > 0) + _flags.Add(word.ToLowerInvariant()); + } + } + + public string Pop() + { + // Loop to ignore and skip past flags + while (NextWordPosition(_ptr) is { } pos) + { + _ptr = pos.endPos + pos.advanceAfterWord; + if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue; + return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim(); + } + + return ""; + } + + public string Peek() + { + // temp ptr so we don't move the real ptr + int ptr = _ptr; + + return PeekWithPtr(ref ptr); + } + + public string PeekWithPtr(ref int ptr) + { + // Loop to ignore and skip past flags + while (NextWordPosition(ptr) is { } pos) + { + ptr = pos.endPos + pos.advanceAfterWord; + if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue; + return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim(); + } + + return ""; + } + + public ISet Flags() + { + if (_flags == null) ParseFlags(); + return _flags; + } + + public string Remainder(bool skipFlags = true) + { + if (skipFlags) + // Skip all *leading* flags when taking the remainder + while (NextWordPosition(_ptr) is { } wp) + { + if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) break; + _ptr = wp.endPos + wp.advanceAfterWord; + } + + // *Then* get the remainder + return FullCommand.Substring(Math.Min(_ptr, FullCommand.Length)).Trim(); + } + + private WordPosition? NextWordPosition(int position) + { + // Skip leading spaces before actual content + while (position < FullCommand.Length && FullCommand[position] == ' ') position++; + + // Is this the end of the string? + if (FullCommand.Length <= position) return null; + + // Is this a quoted word? + if (TryCheckQuote(FullCommand[position], out var endQuotes)) + { + // We found a quoted word - find an instance of one of the corresponding end quotes + var endQuotePosition = -1; + for (var i = position + 1; i < FullCommand.Length; i++) + if (endQuotePosition == -1 && endQuotes.Contains(FullCommand[i])) + endQuotePosition = i; // need a break; don't feel like brackets tho lol + + // Position after the end quote should be EOL or a space + // Otherwise we fallthrough to the unquoted word handler below + if (FullCommand.Length == endQuotePosition + 1 || FullCommand[endQuotePosition + 1] == ' ') + return new WordPosition(position + 1, endQuotePosition, 2, true); + } + + // Not a quoted word, just find the next space and return if it's the end of the command + var wordEnd = FullCommand.IndexOf(' ', position + 1); + + return wordEnd == -1 + ? new WordPosition(position, FullCommand.Length, 0, false) + : new WordPosition(position, wordEnd, 1, false); + } + + private bool TryCheckQuote(char potentialLeftQuote, out string correspondingRightQuotes) + { + foreach (var (left, right) in _quotePairs) + if (left.Contains(potentialLeftQuote)) + { + correspondingRightQuotes = right; + return true; + } + + correspondingRightQuotes = null; + return false; + } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Admin.cs b/PluralKit.Bot/Commands/Admin.cs new file mode 100644 index 00000000..3a0d3823 --- /dev/null +++ b/PluralKit.Bot/Commands/Admin.cs @@ -0,0 +1,145 @@ +using System.Text.RegularExpressions; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class Admin +{ + private readonly BotConfig _botConfig; + + public Admin(BotConfig botConfig) + { + _botConfig = botConfig; + } + + public async Task UpdateSystemId(Context ctx) + { + ctx.AssertBotAdmin(); + + var target = await ctx.MatchSystem(); + if (target == null) + throw new PKError("Unknown system."); + + var newHid = ctx.PopArgument(); + if (!Regex.IsMatch(newHid, "^[a-z]{5}$")) + throw new PKError($"Invalid new system ID `{newHid}`."); + + var existingSystem = await ctx.Repository.GetSystemByHid(newHid); + if (existingSystem != null) + throw new PKError($"Another system already exists with ID `{newHid}`."); + + if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change")) + throw new PKError("ID change cancelled."); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Hid = newHid }); + await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`)."); + } + + public async Task UpdateMemberId(Context ctx) + { + ctx.AssertBotAdmin(); + + var target = await ctx.MatchMember(); + if (target == null) + throw new PKError("Unknown member."); + + var newHid = ctx.PopArgument(); + if (!Regex.IsMatch(newHid, "^[a-z]{5}$")) + throw new PKError($"Invalid new member ID `{newHid}`."); + + var existingMember = await ctx.Repository.GetMemberByHid(newHid); + if (existingMember != null) + throw new PKError($"Another member already exists with ID `{newHid}`."); + + if (!await ctx.PromptYesNo( + $"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?", + "Change" + )) + throw new PKError("ID change cancelled."); + + await ctx.Repository.UpdateMember(target.Id, new MemberPatch { Hid = newHid }); + await ctx.Reply($"{Emojis.Success} Member ID updated (`{target.Hid}` -> `{newHid}`)."); + } + + public async Task UpdateGroupId(Context ctx) + { + ctx.AssertBotAdmin(); + + var target = await ctx.MatchGroup(); + if (target == null) + throw new PKError("Unknown group."); + + var newHid = ctx.PopArgument(); + if (!Regex.IsMatch(newHid, "^[a-z]{5}$")) + throw new PKError($"Invalid new group ID `{newHid}`."); + + var existingGroup = await ctx.Repository.GetGroupByHid(newHid); + if (existingGroup != null) + throw new PKError($"Another group already exists with ID `{newHid}`."); + + if (!await ctx.PromptYesNo($"Change group ID of **{target.Name}** (`{target.Hid}`) to `{newHid}`?", + "Change" + )) + throw new PKError("ID change cancelled."); + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Hid = newHid }); + await ctx.Reply($"{Emojis.Success} Group ID updated (`{target.Hid}` -> `{newHid}`)."); + } + + public async Task SystemMemberLimit(Context ctx) + { + ctx.AssertBotAdmin(); + + var target = await ctx.MatchSystem(); + if (target == null) + throw new PKError("Unknown system."); + + var config = await ctx.Repository.GetSystemConfig(target.Id); + + var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount; + if (!ctx.HasNext()) + { + await ctx.Reply($"Current member limit is **{currentLimit}** members."); + return; + } + + var newLimitStr = ctx.PopArgument(); + if (!int.TryParse(newLimitStr, out var newLimit)) + throw new PKError($"Couldn't parse `{newLimitStr}` as number."); + + if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update")) + throw new PKError("Member limit change cancelled."); + + await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { MemberLimitOverride = newLimit }); + await ctx.Reply($"{Emojis.Success} Member limit updated."); + } + + public async Task SystemGroupLimit(Context ctx) + { + ctx.AssertBotAdmin(); + + var target = await ctx.MatchSystem(); + if (target == null) + throw new PKError("Unknown system."); + + var config = await ctx.Repository.GetSystemConfig(target.Id); + + var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount; + if (!ctx.HasNext()) + { + await ctx.Reply($"Current group limit is **{currentLimit}** groups."); + return; + } + + var newLimitStr = ctx.PopArgument(); + if (!int.TryParse(newLimitStr, out var newLimit)) + throw new PKError($"Couldn't parse `{newLimitStr}` as number."); + + if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update")) + throw new PKError("Group limit change cancelled."); + + await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { GroupLimitOverride = newLimit }); + await ctx.Reply($"{Emojis.Success} Group limit updated."); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs new file mode 100644 index 00000000..2396a347 --- /dev/null +++ b/PluralKit.Bot/Commands/Api.cs @@ -0,0 +1,167 @@ +using System.Text.RegularExpressions; + +using Myriad.Extensions; +using Myriad.Rest.Exceptions; +using Myriad.Rest.Types.Requests; +using Myriad.Types; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class Api +{ + private static readonly Regex _webhookRegex = + new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)"); + + private readonly BotConfig _botConfig; + private readonly DispatchService _dispatch; + private readonly PrivateChannelService _dmCache; + + public Api(BotConfig botConfig, DispatchService dispatch, PrivateChannelService dmCache) + { + _botConfig = botConfig; + _dispatch = dispatch; + _dmCache = dmCache; + } + + public async Task GetToken(Context ctx) + { + ctx.CheckSystem(); + + // Get or make a token + var token = ctx.System.Token ?? await MakeAndSetNewToken(ctx, ctx.System); + + try + { + // DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile) + var dm = await _dmCache.GetOrCreateDmChannel(ctx.Author.Id); + await ctx.Rest.CreateMessage(dm, + new MessageRequest + { + Content = $"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure." + + $" If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:" + }); + await ctx.Rest.CreateMessage(dm, new MessageRequest { Content = token }); + + if (_botConfig.IsBetaBot) + await ctx.Rest.CreateMessage(dm, new MessageRequest + { + Content = $"{Emojis.Note} The beta bot's API base URL is currently <{_botConfig.BetaBotAPIUrl}>." + + " You need to use this URL instead of the base URL listed on the documentation website." + }); + + // If we're not already in a DM, reply with a reminder to check + if (ctx.Channel.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Success} Check your DMs!"); + } + catch (ForbiddenException) + { + // Can't check for permission errors beforehand, so have to handle here :/ + if (ctx.Channel.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); + } + } + + private async Task MakeAndSetNewToken(Context ctx, PKSystem system) + { + system = await ctx.Repository.UpdateSystem(system.Id, new SystemPatch { Token = StringUtils.GenerateToken() }); + return system.Token; + } + + public async Task RefreshToken(Context ctx) + { + ctx.CheckSystem(); + + if (ctx.System.Token == null) + { + // If we don't have a token, call the other method instead + // This does pretty much the same thing, except words the messages more appropriately for that :) + await GetToken(ctx); + return; + } + + try + { + // DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile) + var dm = await _dmCache.GetOrCreateDmChannel(ctx.Author.Id); + await ctx.Rest.CreateMessage(dm, + new MessageRequest + { + Content = $"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:" + }); + + // Make the new token after sending the first DM; this ensures if we can't DM, we also don't end up + // breaking their existing token as a side effect :) + var token = await MakeAndSetNewToken(ctx, ctx.System); + await ctx.Rest.CreateMessage(dm, new MessageRequest { Content = token }); + + if (_botConfig.IsBetaBot) + await ctx.Rest.CreateMessage(dm, new MessageRequest + { + Content = $"{Emojis.Note} The beta bot's API base URL is currently <{_botConfig.BetaBotAPIUrl}>." + + " You need to use this URL instead of the base URL listed on the documentation website." + }); + + // If we're not already in a DM, reply with a reminder to check + if (ctx.Channel.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Success} Check your DMs!"); + } + catch (ForbiddenException) + { + // Can't check for permission errors beforehand, so have to handle here :/ + if (ctx.Channel.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); + } + } + + public async Task SystemWebhook(Context ctx) + { + ctx.CheckSystem().CheckDMContext(); + + if (!ctx.HasNext(false)) + { + if (ctx.System.WebhookUrl == null) + await ctx.Reply("Your system does not have a webhook URL set. Set one with `pk;system webhook `!"); + else + await ctx.Reply($"Your system's webhook URL is <{ctx.System.WebhookUrl}>."); + return; + } + + if (await ctx.MatchClear("your system's webhook URL")) + { + await ctx.Repository.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = null, WebhookToken = null }); + + await ctx.Reply($"{Emojis.Success} System webhook URL removed."); + return; + } + + var newUrl = ctx.RemainderOrNull(); + if (!await DispatchExt.ValidateUri(newUrl)) + throw new PKError($"The URL {newUrl.AsCode()} is invalid or I cannot access it. Are you sure this is a valid, publicly accessible URL?"); + + if (_webhookRegex.IsMatch(newUrl)) + throw new PKError("PluralKit does not currently support setting a Discord webhook URL as your system's webhook URL."); + + try + { + await _dispatch.DoPostRequest(ctx.System.Id, newUrl, null, true); + } + catch (Exception e) + { + throw new PKError($"Could not verify that the new URL is working: {e.Message}"); + } + + var newToken = StringUtils.GenerateToken(); + + await ctx.Repository.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = newUrl, WebhookToken = newToken }); + + await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system." + + $"\n\n{Emojis.Warn} The following token is used to authenticate requests from PluralKit to you." + + " If it leaks, you should clear and re-set the webhook URL to get a new token." + + "\ntodo: add link to docs or something" + ); + + await ctx.Reply(newToken); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index 39615607..237708a3 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -1,214 +1,149 @@ -using System; -using System.Threading.Tasks; - -using DSharpPlus.Entities; - -using Humanizer; - -using NodaTime; +using Myriad.Builders; +using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Autoproxy { - public class Autoproxy + public async Task SetAutoproxyMode(Context ctx) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + // no need to check account here, it's already done at CommandTree + ctx.CheckGuildContext(); - public Autoproxy(IDatabase db, ModelRepository repo) + // for now, just for guild + // this also creates settings if there are none present + var settings = await ctx.Repository.GetAutoproxySettings(ctx.System.Id, ctx.Guild.Id, null); + + if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) + await AutoproxyOff(ctx, settings); + else if (ctx.Match("latch", "last", "proxy", "stick", "sticky", "l")) + await AutoproxyLatch(ctx, settings); + else if (ctx.Match("front", "fronter", "switch", "f")) + await AutoproxyFront(ctx, settings); + else if (ctx.Match("member")) + throw new PKSyntaxError("Member-mode autoproxy must target a specific member. Use the `pk;autoproxy ` command, where `member` is the name or ID of a member in your system."); + else if (await ctx.MatchMember() is PKMember member) + await AutoproxyMember(ctx, member); + else if (!ctx.HasNext()) + await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx, settings)); + else + throw new PKSyntaxError($"Invalid autoproxy mode {ctx.PopArgument().AsCode()}."); + } + + private async Task AutoproxyOff(Context ctx, AutoproxySettings settings) + { + if (settings.AutoproxyMode == AutoproxyMode.Off) { - _db = db; - _repo = repo; + await ctx.Reply($"{Emojis.Note} Autoproxy is already off in this server."); } - - public async Task SetAutoproxyMode(Context ctx) + else { - // no need to check account here, it's already done at CommandTree - ctx.CheckGuildContext(); - - if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) - await AutoproxyOff(ctx); - else if (ctx.Match("latch", "last", "proxy", "stick", "sticky")) - await AutoproxyLatch(ctx); - else if (ctx.Match("front", "fronter", "switch")) - await AutoproxyFront(ctx); - else if (ctx.Match("member")) - throw new PKSyntaxError("Member-mode autoproxy must target a specific member. Use the `pk;autoproxy ` command, where `member` is the name or ID of a member in your system."); - else if (await ctx.MatchMember() is PKMember member) - await AutoproxyMember(ctx, member); - else if (!ctx.HasNext()) - await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx)); - else - throw new PKSyntaxError($"Invalid autoproxy mode {ctx.PopArgument().AsCode()}."); + await UpdateAutoproxy(ctx, AutoproxyMode.Off, null); + await ctx.Reply($"{Emojis.Success} Autoproxy turned off in this server."); } + } - private async Task AutoproxyOff(Context ctx) + private async Task AutoproxyLatch(Context ctx, AutoproxySettings settings) + { + if (settings.AutoproxyMode == AutoproxyMode.Latch) { - if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Off) - await ctx.Reply($"{Emojis.Note} Autoproxy is already off in this server."); - else - { - await UpdateAutoproxy(ctx, AutoproxyMode.Off, null); - await ctx.Reply($"{Emojis.Success} Autoproxy turned off in this server."); - } + await ctx.Reply($"{Emojis.Note} Autoproxy is already set to latch mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`."); } - - private async Task AutoproxyLatch(Context ctx) + else { - if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Latch) - await ctx.Reply($"{Emojis.Note} Autoproxy is already set to latch mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`."); - else - { - await UpdateAutoproxy(ctx, AutoproxyMode.Latch, null); - await ctx.Reply($"{Emojis.Success} Autoproxy set to latch mode in this server. Messages will now be autoproxied using the *last-proxied member* in this server."); - } + await UpdateAutoproxy(ctx, AutoproxyMode.Latch, null); + await ctx.Reply($"{Emojis.Success} Autoproxy set to latch mode in this server. Messages will now be autoproxied using the *last-proxied member* in this server."); } + } - private async Task AutoproxyFront(Context ctx) + private async Task AutoproxyFront(Context ctx, AutoproxySettings settings) + { + if (settings.AutoproxyMode == AutoproxyMode.Front) { - if (ctx.MessageContext.AutoproxyMode == AutoproxyMode.Front) - await ctx.Reply($"{Emojis.Note} Autoproxy is already set to front mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`."); - else - { - await UpdateAutoproxy(ctx, AutoproxyMode.Front, null); - await ctx.Reply($"{Emojis.Success} Autoproxy set to front mode in this server. Messages will now be autoproxied using the *current first fronter*, if any."); - } + await ctx.Reply($"{Emojis.Note} Autoproxy is already set to front mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`."); } - - private async Task AutoproxyMember(Context ctx, PKMember member) + else { - ctx.CheckOwnMember(member); - - await UpdateAutoproxy(ctx, AutoproxyMode.Member, member.Id); - await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server."); + await UpdateAutoproxy(ctx, AutoproxyMode.Front, null); + await ctx.Reply($"{Emojis.Success} Autoproxy set to front mode in this server. Messages will now be autoproxied using the *current first fronter*, if any."); } + } - private async Task CreateAutoproxyStatusEmbed(Context ctx) + private async Task AutoproxyMember(Context ctx, PKMember member) + { + ctx.CheckOwnMember(member); + + // todo: why does this not throw an error if the member is already set + + await UpdateAutoproxy(ctx, AutoproxyMode.Member, member.Id); + await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server."); + } + + private async Task CreateAutoproxyStatusEmbed(Context ctx, AutoproxySettings settings) + { + var commandList = "**pk;autoproxy latch** - Autoproxies as last-proxied member" + + "\n**pk;autoproxy front** - Autoproxies as current (first) fronter" + + "\n**pk;autoproxy ** - Autoproxies as a specific member"; + var eb = new EmbedBuilder() + .Title($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})"); + + var fronters = ctx.MessageContext.LastSwitchMembers; + var relevantMember = settings.AutoproxyMode switch { - var commandList = "**pk;autoproxy latch** - Autoproxies as last-proxied member\n**pk;autoproxy front** - Autoproxies as current (first) fronter\n**pk;autoproxy ** - Autoproxies as a specific member"; - var eb = new DiscordEmbedBuilder().WithTitle($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})"); - - var fronters = ctx.MessageContext.LastSwitchMembers; - var relevantMember = ctx.MessageContext.AutoproxyMode switch - { - AutoproxyMode.Front => fronters.Length > 0 ? await _db.Execute(c => _repo.GetMember(c, fronters[0])) : null, - AutoproxyMode.Member => await _db.Execute(c => _repo.GetMember(c, ctx.MessageContext.AutoproxyMember.Value)), - _ => null - }; + AutoproxyMode.Front => fronters.Length > 0 ? await ctx.Repository.GetMember(fronters[0]) : null, + AutoproxyMode.Member when settings.AutoproxyMember.HasValue => await ctx.Repository.GetMember(settings.AutoproxyMember.Value), + _ => null + }; - switch (ctx.MessageContext.AutoproxyMode) { - case AutoproxyMode.Off: eb.WithDescription($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}"); - break; - case AutoproxyMode.Front: + switch (settings.AutoproxyMode) + { + case AutoproxyMode.Off: + eb.Description($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}"); + break; + case AutoproxyMode.Front: { if (fronters.Length == 0) - eb.WithDescription("Autoproxy is currently set to **front mode** in this server, but there are currently no fronters registered. Use the `pk;switch` command to log a switch."); + { + eb.Description("Autoproxy is currently set to **front mode** in this server, but there are currently no fronters registered. Use the `pk;switch` command to log a switch."); + } else { - if (relevantMember == null) + if (relevantMember == null) throw new ArgumentException("Attempted to print member autoproxy status, but the linked member ID wasn't found in the database. Should be handled appropriately."); - eb.WithDescription($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`."); + eb.Description($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`."); } break; } - // AutoproxyMember is never null if Mode is Member, this is just to make the compiler shut up - case AutoproxyMode.Member when relevantMember != null: { - eb.WithDescription($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`."); + case AutoproxyMode.Member: + { + if (relevantMember == null) + // just pretend autoproxy is off if the member was deleted + // ideally we would set it to off in the database though... + eb.Description($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}"); + else + eb.Description($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`."); + break; } - case AutoproxyMode.Latch: - eb.WithDescription("Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. To disable, type `pk;autoproxy off`."); - break; - - default: throw new ArgumentOutOfRangeException(); - } + case AutoproxyMode.Latch: + eb.Description("Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. To disable, type `pk;autoproxy off`."); + break; - if (!ctx.MessageContext.AllowAutoproxy) - eb.AddField("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`."); - - return eb.Build(); + default: throw new ArgumentOutOfRangeException(); } - public async Task AutoproxyTimeout(Context ctx) - { - if (!ctx.HasNext()) - { - var timeout = ctx.System.LatchTimeout.HasValue - ? Duration.FromSeconds(ctx.System.LatchTimeout.Value) - : (Duration?) null; - - if (timeout == null) - await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize()}."); - else if (timeout == Duration.Zero) - await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out."); - else - await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize()}."); - return; - } + if (!ctx.MessageContext.AllowAutoproxy) + eb.Field(new Embed.Field("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`.")); - // todo: somehow parse a more human-friendly date format - int newTimeoutHours; - if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeoutHours = 0; - else if (ctx.Match("reset", "default")) newTimeoutHours = -1; - else if (!int.TryParse(ctx.RemainderOrNull(), out newTimeoutHours)) throw new PKError("Duration must be a number of hours."); + return eb.Build(); + } - int? overflow = null; - if (newTimeoutHours > 100000) - { - // sanity check to prevent seconds overflow if someone types in 999999999 - overflow = newTimeoutHours; - newTimeoutHours = 0; - } - - var newTimeout = newTimeoutHours > -1 ? Duration.FromHours(newTimeoutHours) : (Duration?) null; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, - new SystemPatch { LatchTimeout = (int?) newTimeout?.TotalSeconds })); - - if (newTimeoutHours == -1) - await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize()})."); - else if (newTimeoutHours == 0 && overflow != null) - await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow} hours is too long)"); - else if (newTimeoutHours == 0) - await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out."); - else - await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.Value!.ToTimeSpan().Humanize()}."); - } - - public async Task AutoproxyAccount(Context ctx) - { - // todo: this might be useful elsewhere, consider moving it to ctx.MatchToggle - if (ctx.Match("enable", "on")) - await AutoproxyEnableDisable(ctx, true); - else if (ctx.Match("disable", "off")) - await AutoproxyEnableDisable(ctx, false); - else if (ctx.HasNext()) - throw new PKSyntaxError("You must pass either \"on\" or \"off\"."); - else - { - var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled"; - await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>.", mentions: new IMention[]{}); - } - } - - private async Task AutoproxyEnableDisable(Context ctx, bool allow) - { - var statusString = allow ? "enabled" : "disabled"; - if (ctx.MessageContext.AllowAutoproxy == allow) - { - await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.", mentions: new IMention[]{}); - return; - } - var patch = new AccountPatch { AllowAutoproxy = allow }; - await _db.Execute(conn => _repo.UpdateAccount(conn, ctx.Author.Id, patch)); - await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.", mentions: new IMention[]{}); - } - - private Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) - { - var patch = new SystemGuildPatch {AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember}; - return _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.Guild.Id, patch)); - } + private async Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) + { + var patch = new AutoproxyPatch { AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember }; + await ctx.Repository.UpdateAutoproxy(ctx.System.Id, ctx.Guild.Id, null, patch); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs b/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs deleted file mode 100644 index 045f52e5..00000000 --- a/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs +++ /dev/null @@ -1,76 +0,0 @@ -#nullable enable -using System; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; - -using DSharpPlus; -using DSharpPlus.Entities; - -namespace PluralKit.Bot -{ - public static class ContextAvatarExt - { - // Rewrite cdn.discordapp.com URLs to media.discordapp.net for jpg/png files - // This lets us add resizing parameters to "borrow" their media proxy server to downsize the image - // which in turn makes it more likely to be underneath the size limit! - private static readonly Regex DiscordCdnUrl = new Regex(@"^https?://(?:cdn\.discordapp\.com|media\.discordapp\.net)/attachments/(\d{17,19})/(\d{17,19})/([^/\\&\?]+)\.(png|jpg|jpeg|webp)(\?.*)?$"); - private static readonly string DiscordMediaUrlReplacement = "https://media.discordapp.net/attachments/$1/$2/$3.$4?width=256&height=256"; - - public static async Task MatchImage(this Context ctx) - { - // If we have a user @mention/ID, use their avatar - if (await ctx.MatchUser() is { } user) - { - var url = user.GetAvatarUrl(ImageFormat.Png, 256); - return new ParsedImage {Url = url, Source = AvatarSource.User, SourceUser = user}; - } - - // If we have a positional argument, try to parse it as a URL - var arg = ctx.RemainderOrNull(); - if (arg != null) - { - // Allow surrounding the URL with to "de-embed" - if (arg.StartsWith("<") && arg.EndsWith(">")) - arg = arg.Substring(1, arg.Length - 2); - - if (!Uri.TryCreate(arg, UriKind.Absolute, out var uri)) - throw Errors.InvalidUrl(arg); - - if (uri.Scheme != "http" && uri.Scheme != "https") - throw Errors.InvalidUrl(arg); - - // ToString URL-decodes, which breaks URLs to spaces; AbsoluteUri doesn't - return new ParsedImage {Url = TryRewriteCdnUrl(uri.AbsoluteUri), Source = AvatarSource.Url}; - } - - // If we have an attachment, use that - if (ctx.Message.Attachments.FirstOrDefault() is {} attachment) - { - var url = TryRewriteCdnUrl(attachment.ProxyUrl); - return new ParsedImage {Url = url, Source = AvatarSource.Attachment}; - } - - // We should only get here if there are no arguments (which would get parsed as URL + throw if error) - // and if there are no attachments (which would have been caught just before) - return null; - } - - private static string TryRewriteCdnUrl(string url) => - DiscordCdnUrl.Replace(url, DiscordMediaUrlReplacement); - } - - public struct ParsedImage - { - public string Url; - public AvatarSource Source; - public DiscordUser? SourceUser; - } - - public enum AvatarSource - { - Url, - User, - Attachment - } -} diff --git a/PluralKit.Bot/Commands/Checks.cs b/PluralKit.Bot/Commands/Checks.cs new file mode 100644 index 00000000..05623a4d --- /dev/null +++ b/PluralKit.Bot/Commands/Checks.cs @@ -0,0 +1,281 @@ +using Humanizer; + +using Myriad.Builders; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Rest; +using Myriad.Rest.Exceptions; +using Myriad.Types; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class Checks +{ + private readonly BotConfig _botConfig; + // this must ONLY be used to get the bot's user ID + private readonly IDiscordCache _cache; + private readonly ProxyMatcher _matcher; + private readonly ProxyService _proxy; + private readonly DiscordApiClient _rest; + + private readonly PermissionSet[] requiredPermissions = + { + PermissionSet.ViewChannel, PermissionSet.SendMessages, PermissionSet.AddReactions, + PermissionSet.AttachFiles, PermissionSet.EmbedLinks, PermissionSet.ManageMessages, + PermissionSet.ManageWebhooks, PermissionSet.ReadMessageHistory + }; + + // todo: make sure everything uses the minimum amount of REST calls necessary + public Checks(DiscordApiClient rest, IDiscordCache cache, BotConfig botConfig, ProxyService proxy, ProxyMatcher matcher) + { + _rest = rest; + _cache = cache; + _botConfig = botConfig; + _proxy = proxy; + _matcher = matcher; + } + + public async Task PermCheckGuild(Context ctx) + { + Guild guild; + GuildMemberPartial senderGuildUser = null; + + if (ctx.Guild != null && !ctx.HasNext()) + { + guild = ctx.Guild; + senderGuildUser = ctx.Member; + } + else + { + var guildIdStr = ctx.RemainderOrNull() ?? + throw new PKSyntaxError("You must pass a server ID or run this command in a server."); + if (!ulong.TryParse(guildIdStr, out var guildId)) + throw new PKSyntaxError($"Could not parse {guildIdStr.AsCode()} as an ID."); + + try + { + guild = await _rest.GetGuild(guildId); + } + catch (ForbiddenException) + { + throw Errors.GuildNotFound(guildId); + } + + if (guild != null) + senderGuildUser = await _rest.GetGuildMember(guildId, ctx.Author.Id); + if (guild == null || senderGuildUser == null) + throw Errors.GuildNotFound(guildId); + } + + var guildMember = await _rest.GetGuildMember(guild.Id, await _cache.GetOwnUser()); + + // Loop through every channel and group them by sets of permissions missing + var permissionsMissing = new Dictionary>(); + var hiddenChannels = false; + var missingEmojiPermissions = false; + foreach (var channel in await _rest.GetGuildChannels(guild.Id)) + { + var botPermissions = PermissionExtensions.PermissionsFor(guild, channel, await _cache.GetOwnUser(), guildMember); + var webhookPermissions = PermissionExtensions.EveryonePermissions(guild, channel); + var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, senderGuildUser); + + if ((userPermissions & PermissionSet.ViewChannel) == 0) + { + // If the user can't see this channel, don't calculate permissions for it + // (to prevent info-leaking, mostly) + // Instead, show the user that some channels got ignored (so they don't get confused) + hiddenChannels = true; + continue; + } + + // We use a bitfield so we can set individual permission bits in the loop + // TODO: Rewrite with proper bitfield math + ulong missingPermissionField = 0; + + foreach (var requiredPermission in requiredPermissions) + if ((botPermissions & requiredPermission) == 0) + missingPermissionField |= (ulong)requiredPermission; + + if ((webhookPermissions & PermissionSet.UseExternalEmojis) == 0) + { + missingPermissionField |= (ulong)PermissionSet.UseExternalEmojis; + missingEmojiPermissions = true; + } + + // If we're not missing any permissions, don't bother adding it to the dict + // This means we can check if the dict is empty to see if all channels are proxyable + if (missingPermissionField != 0) + { + permissionsMissing.TryAdd(missingPermissionField, new List()); + permissionsMissing[missingPermissionField].Add(channel); + } + } + + // Generate the output embed + var eb = new EmbedBuilder() + .Title($"Permission check for **{guild.Name}**"); + + if (permissionsMissing.Count == 0) + eb.Description("No errors found, all channels proxyable :)").Color(DiscordUtils.Green); + else + foreach (var (missingPermissionField, channels) in permissionsMissing) + { + // Each missing permission field can have multiple missing channels + // so we extract them all and generate a comma-separated list + var missingPermissionNames = ((PermissionSet)missingPermissionField).ToPermissionString(); + + var channelsList = string.Join("\n", channels + .OrderBy(c => c.Position) + .Select(c => $"#{c.Name}")); + eb.Field(new Embed.Field($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000))); + eb.Color(DiscordUtils.Red); + } + + var footer = ""; + if (hiddenChannels) + footer += "Some channels were ignored as you do not have view access to them."; + if (missingEmojiPermissions) + { + if (hiddenChannels) footer += " | "; + footer += + "Use External Emojis permissions must be granted to the @everyone role / Default Permissions."; + } + + if (footer.Length > 0) + eb.Footer(new Embed.EmbedFooter(footer)); + + // Send! :) + await ctx.Reply(embed: eb.Build()); + } + + public async Task PermCheckChannel(Context ctx) + { + if (!ctx.HasNext()) + throw new PKSyntaxError("You need to specify a channel."); + + var error = "Channel not found or you do not have permissions to access it."; + + // todo: this breaks if channel is not in cache and bot does not have View Channel permissions + var channel = await ctx.MatchChannel(); + if (channel == null || channel.GuildId == null) + throw new PKError(error); + + var guild = await _rest.GetGuildOrNull(channel.GuildId.Value); + if (guild == null) + throw new PKError(error); + + var guildMember = await _rest.GetGuildMember(channel.GuildId.Value, await _cache.GetOwnUser()); + + if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel)) + throw new PKError(error); + + var botPermissions = PermissionExtensions.PermissionsFor(guild, channel, await _cache.GetOwnUser(), guildMember); + var webhookPermissions = PermissionExtensions.EveryonePermissions(guild, channel); + + // We use a bitfield so we can set individual permission bits + ulong missingPermissions = 0; + + foreach (var requiredPermission in requiredPermissions) + if ((botPermissions & requiredPermission) == 0) + missingPermissions |= (ulong)requiredPermission; + + if ((webhookPermissions & PermissionSet.UseExternalEmojis) == 0) + missingPermissions |= (ulong)PermissionSet.UseExternalEmojis; + + // Generate the output embed + var eb = new EmbedBuilder() + .Title($"Permission check for **{channel.Name}**"); + + if (missingPermissions == 0) + { + eb.Description("No issues found, channel is proxyable :)"); + } + else + { + var missing = ""; + + foreach (var permission in requiredPermissions) + if (((ulong)permission & missingPermissions) == (ulong)permission) + missing += $"\n- **{permission.ToPermissionString()}**"; + + if (((ulong)PermissionSet.UseExternalEmojis & missingPermissions) == + (ulong)PermissionSet.UseExternalEmojis) + missing += $"\n- **{PermissionSet.UseExternalEmojis.ToPermissionString()}**"; + + eb.Description($"Missing permissions:\n{missing}"); + } + + await ctx.Reply(embed: eb.Build()); + } + + public async Task MessageProxyCheck(Context ctx) + { + if (!ctx.HasNext() && ctx.Message.MessageReference == null) + throw new PKSyntaxError("You need to specify a message."); + + var failedToGetMessage = + "Could not find a valid message to check, was not able to fetch the message, or the message was not sent by you."; + + var (messageId, channelId) = ctx.MatchMessage(false); + if (messageId == null || channelId == null) + throw new PKError(failedToGetMessage); + + var proxiedMsg = await ctx.Database.Execute(conn => ctx.Repository.GetMessage(conn, messageId.Value)); + if (proxiedMsg != null) + { + await ctx.Reply($"{Emojis.Success} This message was proxied successfully."); + return; + } + + // get the message info + var msg = await _rest.GetMessageOrNull(channelId.Value, messageId.Value); + if (msg == null) + throw new PKError(failedToGetMessage); + + // if user is fetching a message in a different channel sent by someone else, throw a generic error message + if (msg == null || msg.Author.Id != ctx.Author.Id && msg.ChannelId != ctx.Channel.Id) + throw new PKError(failedToGetMessage); + + if ((_botConfig.Prefixes ?? BotConfig.DefaultPrefixes).Any(p => msg.Content.StartsWith(p))) + { + await ctx.Reply("This message starts with the bot's prefix, and was parsed as a command."); + return; + } + if (msg.Author.Bot) + throw new PKError("You cannot check messages sent by a bot."); + if (msg.WebhookId != null) + throw new PKError("You cannot check messages sent by a webhook."); + if (msg.Author.Id != ctx.Author.Id && !ctx.CheckBotAdmin()) + throw new PKError("You can only check your own messages."); + + // get the channel info + var channel = await _rest.GetChannelOrNull(channelId.Value); + if (channel == null) + throw new PKError("Unable to get the channel associated with this message."); + + // using channel.GuildId here since _rest.GetMessage() doesn't return the GuildId + var context = await ctx.Repository.GetMessageContext(msg.Author.Id, channel.GuildId.Value, msg.ChannelId); + var members = (await ctx.Repository.GetProxyMembers(msg.Author.Id, channel.GuildId.Value)).ToList(); + + // for now this is just server + var autoproxySettings = await ctx.Repository.GetAutoproxySettings(ctx.System.Id, channel.GuildId.Value, null); + + // todo: match unlatch + + // Run everything through the checks, catch the ProxyCheckFailedException, and reply with the error message. + try + { + _proxy.ShouldProxy(channel, msg, context); + _matcher.TryMatch(context, autoproxySettings, members, out var match, msg.Content, msg.Attachments.Length > 0, + context.AllowAutoproxy); + + await ctx.Reply("I'm not sure why this message was not proxied, sorry."); + } + catch (ProxyService.ProxyChecksFailedException e) + { + await ctx.Reply($"{e.Message}"); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs deleted file mode 100644 index 60f2426f..00000000 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ /dev/null @@ -1,548 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; - -using DSharpPlus; -using DSharpPlus.Exceptions; - -using Humanizer; - -using PluralKit.Core; - -namespace PluralKit.Bot -{ - public class CommandTree - { - public static Command SystemInfo = new Command("system", "system [system]", "Looks up information about a system"); - public static Command SystemNew = new Command("system new", "system new [name]", "Creates a new system"); - public static Command SystemRename = new Command("system name", "system rename [name]", "Renames your system"); - public static Command SystemDesc = new Command("system description", "system description [description]", "Changes your system's description"); - public static Command SystemTag = new Command("system tag", "system tag [tag]", "Changes your system's tag"); - public static Command SystemAvatar = new Command("system icon", "system icon [url|@mention]", "Changes your system's icon"); - public static Command SystemDelete = new Command("system delete", "system delete", "Deletes your system"); - public static Command SystemTimezone = new Command("system timezone", "system timezone [timezone]", "Changes your system's time zone"); - public static Command SystemProxy = new Command("system proxy", "system proxy [on|off]", "Enables or disables message proxying in a specific server"); - public static Command SystemList = new Command("system list", "system [system] list [full]", "Lists a system's members"); - public static Command SystemFind = new Command("system find", "system [system] find [full] ", "Searches a system's members given a search term"); - public static Command SystemFronter = new Command("system fronter", "system [system] fronter", "Shows a system's fronter(s)"); - public static Command SystemFrontHistory = new Command("system fronthistory", "system [system] fronthistory", "Shows a system's front history"); - public static Command SystemFrontPercent = new Command("system frontpercent", "system [system] frontpercent [timespan]", "Shows a system's front breakdown"); - public static Command SystemPing = new Command("system ping", "system ping ", "Changes your system's ping preferences"); - public static Command SystemPrivacy = new Command("system privacy", "system privacy ", "Changes your system's privacy settings"); - public static Command AutoproxySet = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for the current server"); - public static Command AutoproxyTimeout = new Command("autoproxy", "autoproxy timeout [|off|reset]", "Sets the latch timeout duration for your system"); - public static Command AutoproxyAccount = new Command("autoproxy", "autoproxy account [on|off]", "Toggles autoproxy globally for the current account"); - public static Command MemberInfo = new Command("member", "member ", "Looks up information about a member"); - public static Command MemberNew = new Command("member new", "member new ", "Creates a new member"); - public static Command MemberRename = new Command("member rename", "member rename ", "Renames a member"); - public static Command MemberDesc = new Command("member description", "member description [description]", "Changes a member's description"); - public static Command MemberPronouns = new Command("member pronouns", "member pronouns [pronouns]", "Changes a member's pronouns"); - public static Command MemberColor = new Command("member color", "member color [color]", "Changes a member's color"); - public static Command MemberBirthday = new Command("member birthday", "member birthday [birthday]", "Changes a member's birthday"); - public static Command MemberProxy = new Command("member proxy", "member proxy [add|remove] [example proxy]", "Changes, adds, or removes a member's proxy tags"); - public static Command MemberDelete = new Command("member delete", "member delete", "Deletes a member"); - public static Command MemberAvatar = new Command("member avatar", "member avatar [url|@mention]", "Changes a member's avatar"); - public static Command MemberGroups = new Command("member group", "member group", "Shows the groups a member is in"); - public static Command MemberGroupAdd = new Command("member group", "member group add [group 2] [group 3...]", "Adds a member to one or more groups"); - public static Command MemberGroupRemove = new Command("member group", "member group remove [group 2] [group 3...]", "Removes a member from one or more groups"); - public static Command MemberServerAvatar = new Command("member serveravatar", "member serveravatar [url|@mention]", "Changes a member's avatar in the current server"); - public static Command MemberDisplayName = new Command("member displayname", "member displayname [display name]", "Changes a member's display name"); - public static Command MemberServerName = new Command("member servername", "member servername [server name]", "Changes a member's display name in the current server"); - public static Command MemberAutoproxy = new Command("member autoproxy", "member autoproxy [on|off]", "Sets whether a member will be autoproxied when autoproxy is set to latch or front mode."); - public static Command MemberKeepProxy = new Command("member keepproxy", "member keepproxy [on|off]", "Sets whether to include a member's proxy tags when proxying"); - public static Command MemberRandom = new Command("random", "random", "Shows the info card of a randomly selected member in your system."); - public static Command MemberPrivacy = new Command("member privacy", "member privacy ", "Changes a members's privacy settings"); - public static Command GroupInfo = new Command("group", "group ", "Looks up information about a group"); - public static Command GroupNew = new Command("group new", "group new ", "Creates a new group"); - public static Command GroupList = new Command("group list", "group list", "Lists all groups in this system"); - public static Command GroupMemberList = new Command("group members", "group list", "Lists all members in a group"); - public static Command GroupRename = new Command("group rename", "group rename ", "Renames a group"); - public static Command GroupDisplayName = new Command("group displayname", "group displayname [display name]", "Changes a group's display name"); - public static Command GroupDesc = new Command("group description", "group description [description]", "Changes a group's description"); - public static Command GroupAdd = new Command("group add", "group add [member 2] [member 3...]", "Adds one or more members to a group"); - public static Command GroupRemove = new Command("group remove", "group remove [member 2] [member 3...]", "Removes one or more members from a group"); - public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); - public static Command GroupIcon = new Command("group icon", "group icon [url|@mention]", "Changes a group's icon"); - public static Command GroupDelete = new Command("group delete", "group delete", "Deletes a group"); - public static Command GroupMemberRandom = new Command("group random", "group random", "Shows the info card of a randomly selected member in a group."); - public static Command GroupRandom = new Command("random", "random group", "Shows the info card of a randomly selected group in your system."); - public static Command Switch = new Command("switch", "switch [member 2] [member 3...]", "Registers a switch"); - public static Command SwitchOut = new Command("switch out", "switch out", "Registers a switch with no members"); - public static Command SwitchMove = new Command("switch move", "switch move ", "Moves the latest switch in time"); - public static Command SwitchDelete = new Command("switch delete", "switch delete", "Deletes the latest switch"); - public static Command SwitchDeleteAll = new Command("switch delete", "switch delete all", "Deletes all logged switches"); - public static Command Link = new Command("link", "link ", "Links your system to another account"); - public static Command Unlink = new Command("unlink", "unlink [account]", "Unlinks your system from an account"); - public static Command TokenGet = new Command("token", "token", "Gets your system's API token"); - public static Command TokenRefresh = new Command("token refresh", "token refresh", "Resets your system's API token"); - public static Command Import = new Command("import", "import [fileurl]", "Imports system information from a data file"); - public static Command Export = new Command("export", "export", "Exports system information to a data file"); - public static Command Help = new Command("help", "help", "Shows help information about PluralKit"); - public static Command Explain = new Command("explain", "explain", "Explains the basics of systems and proxying"); - public static Command Message = new Command("message", "message ", "Looks up a proxied message"); - public static Command LogChannel = new Command("log channel", "log channel ", "Designates a channel to post proxied messages to"); - public static Command LogChannelClear = new Command("log channel", "log channel -clear", "Clears the currently set log channel"); - public static Command LogEnable = new Command("log enable", "log enable all| [channel 2] [channel 3...]", "Enables message logging in certain channels"); - public static Command LogDisable = new Command("log disable", "log disable all| [channel 2] [channel 3...]", "Disables message logging in certain channels"); - public static Command LogClean = new Command("logclean", "logclean [on|off]", "Toggles whether to clean up other bots' log channels"); - public static Command BlacklistShow = new Command("blacklist show", "blacklist show", "Displays the current proxy blacklist"); - public static Command BlacklistAdd = new Command("blacklist add", "blacklist add all| [channel 2] [channel 3...]", "Adds certain channels to the proxy blacklist"); - public static Command BlacklistRemove = new Command("blacklist remove", "blacklist remove all| [channel 2] [channel 3...]", "Removes certain channels from the proxy blacklist"); - public static Command Invite = new Command("invite", "invite", "Gets a link to invite PluralKit to other servers"); - public static Command PermCheck = new Command("permcheck", "permcheck ", "Checks whether a server's permission setup is correct"); - - public static Command[] SystemCommands = { - SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemDelete, SystemTimezone, - SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent, SystemPrivacy, SystemProxy - }; - - public static Command[] MemberCommands = { - MemberInfo, MemberNew, MemberRename, MemberDisplayName, MemberServerName, MemberDesc, MemberPronouns, - MemberColor, MemberBirthday, MemberProxy, MemberAutoproxy, MemberKeepProxy, MemberGroups, MemberGroupAdd, MemberGroupRemove, - MemberDelete, MemberAvatar, MemberServerAvatar, MemberPrivacy, MemberRandom - }; - - public static Command[] GroupCommands = - { - GroupInfo, GroupList, GroupNew, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, - GroupIcon, GroupPrivacy, GroupDelete - }; - - public static Command[] GroupCommandsTargeted = - { - GroupInfo, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, GroupIcon, GroupPrivacy, - GroupDelete, GroupMemberRandom - }; - - public static Command[] SwitchCommands = {Switch, SwitchOut, SwitchMove, SwitchDelete, SwitchDeleteAll}; - - public static Command[] AutoproxyCommands = {AutoproxySet, AutoproxyTimeout, AutoproxyAccount}; - - public static Command[] LogCommands = {LogChannel, LogChannelClear, LogEnable, LogDisable}; - - public static Command[] BlacklistCommands = {BlacklistAdd, BlacklistRemove, BlacklistShow}; - - private DiscordShardedClient _client; - - public CommandTree(DiscordShardedClient client) - { - - _client = client; - } - - public Task ExecuteCommand(Context ctx) - { - if (ctx.Match("system", "s")) - return HandleSystemCommand(ctx); - if (ctx.Match("member", "m")) - return HandleMemberCommand(ctx); - if (ctx.Match("group", "g")) - return HandleGroupCommand(ctx); - if (ctx.Match("switch", "sw")) - return HandleSwitchCommand(ctx); - if (ctx.Match("commands", "cmd", "c")) - return CommandHelpRoot(ctx); - if (ctx.Match("ap", "autoproxy", "auto")) - return HandleAutoproxyCommand(ctx); - if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd")) - return ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); - if (ctx.Match("link")) - return ctx.Execute(Link, m => m.LinkSystem(ctx)); - if (ctx.Match("unlink")) - return ctx.Execute(Unlink, m => m.UnlinkAccount(ctx)); - if (ctx.Match("token")) - if (ctx.Match("refresh", "renew", "invalidate", "reroll", "regen")) - return ctx.Execute(TokenRefresh, m => m.RefreshToken(ctx)); - else - return ctx.Execute(TokenGet, m => m.GetToken(ctx)); - if (ctx.Match("import")) - return ctx.Execute(Import, m => m.Import(ctx)); - if (ctx.Match("export")) - return ctx.Execute(Export, m => m.Export(ctx)); - if (ctx.Match("help")) - if (ctx.Match("commands")) - return ctx.Reply("For the list of commands, see the website: "); - else if (ctx.Match("proxy")) - return ctx.Reply("The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"); - else return ctx.Execute(Help, m => m.HelpRoot(ctx)); - if (ctx.Match("explain")) - return ctx.Execute(Explain, m => m.Explain(ctx)); - if (ctx.Match("message", "msg")) - return ctx.Execute(Message, m => m.GetMessage(ctx)); - if (ctx.Match("log")) - if (ctx.Match("channel")) - return ctx.Execute(LogChannel, m => m.SetLogChannel(ctx)); - else if (ctx.Match("enable", "on")) - return ctx.Execute(LogEnable, m => m.SetLogEnabled(ctx, true)); - else if (ctx.Match("disable", "off")) - return ctx.Execute(LogDisable, m => m.SetLogEnabled(ctx, false)); - else if (ctx.Match("commands")) - return PrintCommandList(ctx, "message logging", LogCommands); - else return PrintCommandExpectedError(ctx, LogCommands); - if (ctx.Match("logclean")) - return ctx.Execute(LogClean, m => m.SetLogCleanup(ctx)); - if (ctx.Match("blacklist", "bl")) - if (ctx.Match("enable", "on", "add", "deny")) - return ctx.Execute(BlacklistAdd, m => m.SetBlacklisted(ctx, true)); - else if (ctx.Match("disable", "off", "remove", "allow")) - return ctx.Execute(BlacklistRemove, m => m.SetBlacklisted(ctx, false)); - else if (ctx.Match("list", "show")) - return ctx.Execute(BlacklistShow, m => m.ShowBlacklisted(ctx)); - else if (ctx.Match("commands")) - return PrintCommandList(ctx, "channel blacklisting", BlacklistCommands); - else return PrintCommandExpectedError(ctx, BlacklistCommands); - if (ctx.Match("proxy", "enable", "disable")) - return ctx.Execute(SystemProxy, m => m.SystemProxy(ctx)); - if (ctx.Match("invite")) return ctx.Execute(Invite, m => m.Invite(ctx)); - if (ctx.Match("mn")) return ctx.Execute(null, m => m.Mn(ctx)); - if (ctx.Match("fire")) return ctx.Execute(null, m => m.Fire(ctx)); - if (ctx.Match("thunder")) return ctx.Execute(null, m => m.Thunder(ctx)); - if (ctx.Match("freeze")) return ctx.Execute(null, m => m.Freeze(ctx)); - if (ctx.Match("starstorm")) return ctx.Execute(null, m => m.Starstorm(ctx)); - if (ctx.Match("flash")) return ctx.Execute(null, m => m.Flash(ctx)); - if (ctx.Match("stats")) return ctx.Execute(null, m => m.Stats(ctx)); - if (ctx.Match("permcheck")) - return ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx)); - if (ctx.Match("random", "r")) - if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g")) - return ctx.Execute(GroupRandom, r => r.Group(ctx)); - else - return ctx.Execute(MemberRandom, m => m.Member(ctx)); - - // remove compiler warning - return ctx.Reply( - $"{Emojis.Error} Unknown command {ctx.PeekArgument().AsCode()}. For a list of possible commands, see ."); - } - - private async Task HandleSystemCommand(Context ctx) - { - // If we have no parameters, default to self-target - if (!ctx.HasNext()) - await ctx.Execute(SystemInfo, m => m.Query(ctx, ctx.System)); - - // First, we match own-system-only commands (ie. no target system parameter) - else if (ctx.Match("new", "create", "make", "add", "register", "init", "n")) - await ctx.Execute(SystemNew, m => m.New(ctx)); - else if (ctx.Match("name", "rename", "changename")) - await ctx.Execute(SystemRename, m => m.Name(ctx)); - else if (ctx.Match("tag")) - await ctx.Execute(SystemTag, m => m.Tag(ctx)); - else if (ctx.Match("description", "desc", "bio")) - await ctx.Execute(SystemDesc, m => m.Description(ctx)); - else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) - await ctx.Execute(SystemAvatar, m => m.Avatar(ctx)); - else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) - await ctx.Execute(SystemDelete, m => m.Delete(ctx)); - else if (ctx.Match("timezone", "tz")) - await ctx.Execute(SystemTimezone, m => m.SystemTimezone(ctx)); - else if (ctx.Match("proxy")) - await ctx.Execute(SystemProxy, m => m.SystemProxy(ctx)); - else if (ctx.Match("list", "l", "members")) - await ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); - else if (ctx.Match("find", "search", "query", "fd", "s")) - await ctx.Execute(SystemFind, m => m.MemberList(ctx, ctx.System)); - else if (ctx.Match("f", "front", "fronter", "fronters")) - { - if (ctx.Match("h", "history")) - await ctx.Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, ctx.System)); - else if (ctx.Match("p", "percent", "%")) - await ctx.Execute(SystemFrontPercent, m => m.SystemFrontPercent(ctx, ctx.System)); - else - await ctx.Execute(SystemFronter, m => m.SystemFronter(ctx, ctx.System)); - } - else if (ctx.Match("fh", "fronthistory", "history", "switches")) - await ctx.Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, ctx.System)); - else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) - await ctx.Execute(SystemFrontPercent, m => m.SystemFrontPercent(ctx, ctx.System)); - else if (ctx.Match("privacy")) - await ctx.Execute(SystemPrivacy, m => m.SystemPrivacy(ctx)); - else if (ctx.Match("ping")) - await ctx.Execute(SystemPing, m => m.SystemPing(ctx)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "systems", SystemCommands); - else if (ctx.Match("groups", "gs", "g")) - await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); - else if (!ctx.HasNext()) // Bare command - await ctx.Execute(SystemInfo, m => m.Query(ctx, ctx.System)); - else - await HandleSystemCommandTargeted(ctx); - } - - private async Task HandleSystemCommandTargeted(Context ctx) - { - // Commands that have a system target (eg. pk;system fronthistory) - var target = await ctx.MatchSystem(); - if (target == null) - { - var list = CreatePotentialCommandList(SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemDelete, SystemTimezone, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent); - await ctx.Reply( - $"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\nPerhaps you meant to use one of the following commands?\n{list}"); - } - else if (ctx.Match("list", "l", "members")) - await ctx.Execute(SystemList, m => m.MemberList(ctx, target)); - else if (ctx.Match("find", "search", "query", "fd", "s")) - await ctx.Execute(SystemFind, m => m.MemberList(ctx, target)); - else if (ctx.Match("f", "front", "fronter", "fronters")) - { - if (ctx.Match("h", "history")) - await ctx.Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target)); - else if (ctx.Match("p", "percent", "%")) - await ctx.Execute(SystemFrontPercent, m => m.SystemFrontPercent(ctx, target)); - else - await ctx.Execute(SystemFronter, m => m.SystemFronter(ctx, target)); - } - else if (ctx.Match("fh", "fronthistory", "history", "switches")) - await ctx.Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target)); - else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) - await ctx.Execute(SystemFrontPercent, m => m.SystemFrontPercent(ctx, target)); - else if (ctx.Match("info", "view", "show")) - await ctx.Execute(SystemInfo, m => m.Query(ctx, target)); - else if (ctx.Match("groups", "gs")) - await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, target)); - else if (!ctx.HasNext()) - await ctx.Execute(SystemInfo, m => m.Query(ctx, target)); - else - await PrintCommandNotFoundError(ctx, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent, - SystemInfo); - } - - private async Task HandleMemberCommand(Context ctx) - { - if (ctx.Match("new", "n", "add", "create", "register")) - await ctx.Execute(MemberNew, m => m.NewMember(ctx)); - else if (ctx.Match("list")) - await ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "members", MemberCommands); - else if (await ctx.MatchMember() is PKMember target) - await HandleMemberCommandTargeted(ctx, target); - else if (!ctx.HasNext()) - await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName, MemberServerName, MemberDesc, MemberPronouns, - MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar); - else - await ctx.Reply($"{Emojis.Error} {ctx.CreateMemberNotFoundError(ctx.PopArgument())}"); - } - - private async Task HandleMemberCommandTargeted(Context ctx, PKMember target) - { - // Commands that have a member target (eg. pk;member delete) - if (ctx.Match("rename", "name", "changename", "setname")) - await ctx.Execute(MemberRename, m => m.Name(ctx, target)); - else if (ctx.Match("description", "info", "bio", "text", "desc")) - await ctx.Execute(MemberDesc, m => m.Description(ctx, target)); - else if (ctx.Match("pronouns", "pronoun")) - await ctx.Execute(MemberPronouns, m => m.Pronouns(ctx, target)); - else if (ctx.Match("color", "colour")) - await ctx.Execute(MemberColor, m => m.Color(ctx, target)); - else if (ctx.Match("birthday", "bday", "birthdate", "cakeday", "bdate")) - await ctx.Execute(MemberBirthday, m => m.Birthday(ctx, target)); - else if (ctx.Match("proxy", "tags", "proxytags", "brackets")) - await ctx.Execute(MemberProxy, m => m.Proxy(ctx, target)); - else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) - await ctx.Execute(MemberDelete, m => m.Delete(ctx, target)); - else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic")) - await ctx.Execute(MemberAvatar, m => m.Avatar(ctx, target)); - else if (ctx.Match("group", "groups")) - if (ctx.Match("add", "a")) - await ctx.Execute(MemberGroupAdd, m => m.AddRemove(ctx, target, Groups.AddRemoveOperation.Add)); - else if (ctx.Match("remove", "rem")) - await ctx.Execute(MemberGroupRemove, m => m.AddRemove(ctx, target, Groups.AddRemoveOperation.Remove)); - else - await ctx.Execute(MemberGroups, m => m.List(ctx, target)); - else if (ctx.Match("serveravatar", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic", "guildavatar", "guildpic", "guildicon", "sicon")) - await ctx.Execute(MemberServerAvatar, m => m.ServerAvatar(ctx, target)); - else if (ctx.Match("displayname", "dn", "dname", "nick", "nickname", "dispname")) - await ctx.Execute(MemberDisplayName, m => m.DisplayName(ctx, target)); - else if (ctx.Match("servername", "sn", "sname", "snick", "snickname", "servernick", "servernickname", "serverdisplayname", "guildname", "guildnick", "guildnickname", "serverdn")) - await ctx.Execute(MemberServerName, m => m.ServerName(ctx, target)); - else if (ctx.Match("autoproxy", "ap")) - await ctx.Execute(MemberAutoproxy, m => m.MemberAutoproxy(ctx, target)); - else if (ctx.Match("keepproxy", "keeptags", "showtags")) - await ctx.Execute(MemberKeepProxy, m => m.KeepProxy(ctx, target)); - else if (ctx.Match("privacy")) - await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, null)); - else if (ctx.Match("private", "hidden", "hide")) - await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private)); - else if (ctx.Match("public", "shown", "show")) - await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public)); - else if (ctx.Match("soulscream")) - await ctx.Execute(MemberInfo, m => m.Soulscream(ctx, target)); - else if (!ctx.HasNext()) // Bare command - await ctx.Execute(MemberInfo, m => m.ViewMember(ctx, target)); - else - await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberServerName ,MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar, SystemList); - } - - private async Task HandleGroupCommand(Context ctx) - { - // Commands with no group argument - if (ctx.Match("n", "new")) - await ctx.Execute(GroupNew, g => g.CreateGroup(ctx)); - else if (ctx.Match("list", "l")) - await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "groups", GroupCommands); - else if (await ctx.MatchGroup() is {} target) - { - // Commands with group argument - if (ctx.Match("rename", "name", "changename", "setname")) - await ctx.Execute(GroupRename, g => g.RenameGroup(ctx, target)); - else if (ctx.Match("nick", "dn", "displayname", "nickname")) - await ctx.Execute(GroupDisplayName, g => g.GroupDisplayName(ctx, target)); - else if (ctx.Match("description", "info", "bio", "text", "desc")) - await ctx.Execute(GroupDesc, g => g.GroupDescription(ctx, target)); - else if (ctx.Match("add", "a")) - await ctx.Execute(GroupAdd,g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add)); - else if (ctx.Match("remove", "rem", "r")) - await ctx.Execute(GroupRemove, g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove)); - else if (ctx.Match("members", "list", "ms", "l")) - await ctx.Execute(GroupMemberList, g => g.ListGroupMembers(ctx, target)); - else if (ctx.Match("random")) - await ctx.Execute(GroupMemberRandom, r => r.GroupMember(ctx, target)); - else if (ctx.Match("privacy")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null)); - else if (ctx.Match("public", "pub")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public)); - else if (ctx.Match("private", "priv")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private)); - else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) - await ctx.Execute(GroupDelete, g => g.DeleteGroup(ctx, target)); - else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) - await ctx.Execute(GroupIcon, g => g.GroupIcon(ctx, target)); - else if (!ctx.HasNext()) - await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); - else - await PrintCommandNotFoundError(ctx, GroupCommandsTargeted); - } - else if (!ctx.HasNext()) - await PrintCommandExpectedError(ctx, GroupCommands); - else - await ctx.Reply($"{Emojis.Error} {ctx.CreateGroupNotFoundError(ctx.PopArgument())}"); - } - - private async Task HandleSwitchCommand(Context ctx) - { - if (ctx.Match("out")) - await ctx.Execute(SwitchOut, m => m.SwitchOut(ctx)); - else if (ctx.Match("move", "shift", "offset")) - await ctx.Execute(SwitchMove, m => m.SwitchMove(ctx)); - else if (ctx.Match("delete", "remove", "erase", "cancel", "yeet")) - await ctx.Execute(SwitchDelete, m => m.SwitchDelete(ctx)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "switching", SwitchCommands); - else if (ctx.HasNext()) // there are following arguments - await ctx.Execute(Switch, m => m.SwitchDo(ctx)); - else - await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchDelete, SystemFronter, SystemFrontHistory); - } - - private async Task CommandHelpRoot(Context ctx) - { - if (!ctx.HasNext()) - { - await ctx.Reply($"{Emojis.Error} You need to pass a target command.\nAvailable command help targets: `system`, `member`, `group`, `switch`, `log`, `blacklist`.\nFor the full list of commands, see the website: "); - return; - } - - switch (ctx.PeekArgument()) { - case "system": - case "systems": - case "s": - await PrintCommandList(ctx, "systems", SystemCommands); - break; - case "member": - case "members": - case "m": - await PrintCommandList(ctx, "members", MemberCommands); - break; - case "group": - case "groups": - case "g": - await PrintCommandList(ctx, "groups", GroupCommands); - break; - case "switch": - case "switches": - case "switching": - case "sw": - await PrintCommandList(ctx, "switching", SwitchCommands); - break; - case "log": - await PrintCommandList(ctx, "message logging", LogCommands); - break; - case "blacklist": - case "bl": - await PrintCommandList(ctx, "channel blacklisting", BlacklistCommands); - break; - case "autoproxy": - case "ap": - await PrintCommandList(ctx, "autoproxy", AutoproxyCommands); - break; - // todo: are there any commands that still need to be added? - default: - await ctx.Reply("For the full list of commands, see the website: "); - break; - } - } - - private Task HandleAutoproxyCommand(Context ctx) - { - // todo: merge this with the changes from #251 - if (ctx.Match("commands")) - return PrintCommandList(ctx, "autoproxy", AutoproxyCommands); - - // ctx.CheckSystem(); - // oops, that breaks stuff! PKErrors before ctx.Execute don't actually do anything. - // so we just emulate checking and throwing an error. - if (ctx.System == null) - return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError.Message}"); - - if (ctx.Match("account", "ac")) - return ctx.Execute(AutoproxyAccount, m => m.AutoproxyAccount(ctx)); - else if (ctx.Match("timeout", "tm")) - return ctx.Execute(AutoproxyTimeout, m => m.AutoproxyTimeout(ctx)); - else - return ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx)); - } - - private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands) - { - var commandListStr = CreatePotentialCommandList(potentialCommands); - await ctx.Reply( - $"{Emojis.Error} Unknown command `pk;{ctx.FullCommand().Truncate(100)}`. Perhaps you meant to use one of the following commands?\n{commandListStr}\n\nFor a full list of possible commands, see ."); - } - - private async Task PrintCommandExpectedError(Context ctx, params Command[] potentialCommands) - { - var commandListStr = CreatePotentialCommandList(potentialCommands); - await ctx.Reply( - $"{Emojis.Error} You need to pass a command. Perhaps you meant to use one of the following commands?\n{commandListStr}\n\nFor a full list of possible commands, see ."); - } - - private static string CreatePotentialCommandList(params Command[] potentialCommands) - { - return string.Join("\n", potentialCommands.Select(cmd => $"- **pk;{cmd.Usage}** - *{cmd.Description}*")); - } - - private async Task PrintCommandList(Context ctx, string subject, params Command[] commands) - { - var str = CreatePotentialCommandList(commands); - await ctx.Reply($"Here is a list of commands related to {subject}: \n{str}\nFor a full list of possible commands, see ."); - } - - private async Task CreateSystemNotFoundError(Context ctx) - { - var input = ctx.PopArgument(); - if (input.TryParseMention(out var id)) - { - // Try to resolve the user ID to find the associated account, - // so we can print their username. - var user = await ctx.Shard.GetUser(id); - if (user != null) - return $"Account **{user.Username}#{user.Discriminator}** does not have a system registered."; - else - return $"Account with ID `{id}` not found."; - } - - return $"System with ID {input.AsCode()} not found."; - } - } -} diff --git a/PluralKit.Bot/Commands/Config.cs b/PluralKit.Bot/Commands/Config.cs new file mode 100644 index 00000000..5831abf5 --- /dev/null +++ b/PluralKit.Bot/Commands/Config.cs @@ -0,0 +1,382 @@ +using System.Text; + +using Humanizer; + +using NodaTime; +using NodaTime.Text; +using NodaTime.TimeZones; + +using PluralKit.Core; + +namespace PluralKit.Bot; +public class Config +{ + private record PaginatedConfigItem(string Key, string Description, string? CurrentValue, string DefaultValue); + + public async Task ShowConfig(Context ctx) + { + var items = new List(); + + items.Add(new( + "autoproxy account", + "Whether autoproxy is enabled for the current account", + EnabledDisabled(ctx.MessageContext.AllowAutoproxy), + "enabled" + )); + + items.Add(new( + "autoproxy timeout", + "If this is set, latch-mode autoproxy will not keep autoproxying after this amount of time has elapsed since the last message sent in the server", + ctx.Config.LatchTimeout.HasValue + ? ( + ctx.Config.LatchTimeout.Value != 0 + ? Duration.FromSeconds(ctx.Config.LatchTimeout.Value).ToTimeSpan().Humanize(4) + : "disabled" + ) + : null, + ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4) + )); + + items.Add(new( + "timezone", + "The system's time zone - shows timestamps in your local time", + ctx.Config.UiTz, + "UTC" + )); + + items.Add(new( + "ping", + $"Whether other users are able to mention you via a {Emojis.Bell} reaction", + EnabledDisabled(ctx.Config.PingsEnabled), + "enabled" + )); + + items.Add(new( + "private member", + "Whether member privacy is automatically set to private for new members", + EnabledDisabled(ctx.Config.MemberDefaultPrivate), + "disabled" + )); + + items.Add(new( + "private group", + "Whether group privacy is automatically set to private for new groups", + EnabledDisabled(ctx.Config.GroupDefaultPrivate), + "disabled" + )); + + items.Add(new( + "show private", + "Whether private information is shown to linked accounts by default", + ctx.Config.ShowPrivateInfo.ToString().ToLower(), + "true" + )); + + items.Add(new( + "Member limit", + "The maximum number of registered members for your system", + ctx.Config.MemberLimitOverride?.ToString(), + Limits.MaxMemberCount.ToString() + )); + + items.Add(new( + "Group limit", + "The maximum number of registered groups for your system", + ctx.Config.GroupLimitOverride?.ToString(), + Limits.MaxGroupCount.ToString() + )); + + await ctx.Paginate( + items.ToAsyncEnumerable(), + items.Count, + 10, + "Current settings for your system", + ctx.System.Color, + (eb, l) => + { + var description = new StringBuilder(); + + foreach (var item in l) + { + description.Append(item.Key.AsCode()); + description.Append($" **({item.CurrentValue ?? item.DefaultValue})**"); + if (item.CurrentValue != null && item.CurrentValue != item.DefaultValue) + description.Append("\ud83d\udd39"); + + description.AppendLine(); + description.Append(item.Description); + description.AppendLine(); + description.AppendLine(); + } + + eb.Description(description.ToString()); + + // using *large* blue diamond here since it's easier to see in the small footer + eb.Footer(new("\U0001f537 means this setting was changed. Type `pk;config clear` to reset it to the default.")); + + return Task.CompletedTask; + } + ); + } + private string EnabledDisabled(bool value) => value ? "enabled" : "disabled"; + + public async Task AutoproxyAccount(Context ctx) + { + if (!ctx.HasNext()) + { + await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(ctx.MessageContext.AllowAutoproxy)}** for account <@{ctx.Author.Id}>."); + return; + } + + var allow = ctx.MatchToggle(true); + + var statusString = EnabledDisabled(allow); + if (ctx.MessageContext.AllowAutoproxy == allow) + { + await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>."); + return; + } + var patch = new AccountPatch { AllowAutoproxy = allow }; + await ctx.Repository.UpdateAccount(ctx.Author.Id, patch); + await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); + } + + + public async Task AutoproxyTimeout(Context ctx) + { + if (!ctx.HasNext()) + { + var timeout = ctx.Config.LatchTimeout.HasValue + ? Duration.FromSeconds(ctx.Config.LatchTimeout.Value) + : (Duration?)null; + + if (timeout == null) + await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}."); + else if (timeout == Duration.Zero) + await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out."); + else + await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}."); + return; + } + + Duration? newTimeout; + Duration overflow = Duration.Zero; + if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeout = Duration.Zero; + else if (await ctx.MatchClear()) newTimeout = null; + else + { + var timeoutStr = ctx.RemainderOrNull(); + var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr); + if (timeoutPeriod == null) throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes)."); + if (timeoutPeriod.Value.TotalHours > 100000) + { + // sanity check to prevent seconds overflow if someone types in 999999999 + overflow = timeoutPeriod.Value; + newTimeout = Duration.Zero; + } + else newTimeout = timeoutPeriod; + } + + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = (int?)newTimeout?.TotalSeconds }); + + if (newTimeout == null) + await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)})."); + else if (newTimeout == Duration.Zero && overflow != Duration.Zero) + await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow.ToTimeSpan().Humanize(4)} is too long)"); + else if (newTimeout == Duration.Zero) + await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out."); + else + await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.Value!.ToTimeSpan().Humanize(4)}."); + } + + public async Task SystemTimezone(Context ctx) + { + if (ctx.System == null) throw Errors.NoSystemError; + + if (await ctx.MatchClear()) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { UiTz = "UTC" }); + + await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC)."); + return; + } + + var zoneStr = ctx.RemainderOrNull(); + if (zoneStr == null) + { + await ctx.Reply( + $"Your current system time zone is set to **{ctx.Config.UiTz}**. It is currently **{SystemClock.Instance.GetCurrentInstant().FormatZoned(ctx.Config.Zone)}** in that time zone. To change your system time zone, type `pk;config tz `."); + return; + } + + var zone = await FindTimeZone(ctx, zoneStr); + if (zone == null) throw Errors.InvalidTimeZone(zoneStr); + + var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone); + var msg = $"This will change the system time zone to **{zone.Id}**. The current time is **{currentTime.FormatZoned()}**. Is this correct?"; + if (!await ctx.PromptYesNo(msg, "Change Timezone")) throw Errors.TimezoneChangeCancelled; + + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { UiTz = zone.Id }); + + await ctx.Reply($"System time zone changed to **{zone.Id}**."); + } + + + private async Task FindTimeZone(Context ctx, string zoneStr) + { + // First, if we're given a flag emoji, we extract the flag emoji code from it. + zoneStr = Core.StringUtils.ExtractCountryFlag(zoneStr) ?? zoneStr; + + // Then, we find all *locations* matching either the given country code or the country name. + var locations = TzdbDateTimeZoneSource.Default.Zone1970Locations; + var matchingLocations = locations.Where(l => l.Countries.Any(c => + string.Equals(c.Code, zoneStr, StringComparison.InvariantCultureIgnoreCase) || + string.Equals(c.Name, zoneStr, StringComparison.InvariantCultureIgnoreCase))); + + // Then, we find all (unique) time zone IDs that match. + var matchingZones = matchingLocations.Select(l => DateTimeZoneProviders.Tzdb.GetZoneOrNull(l.ZoneId)) + .Distinct().ToList(); + + // If the set of matching zones is empty (ie. we didn't find anything), we try a few other things. + if (matchingZones.Count == 0) + { + // First, we try to just find the time zone given directly and return that. + var givenZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(zoneStr); + if (givenZone != null) return givenZone; + + // If we didn't find anything there either, we try parsing the string as an offset, then + // find all possible zones that match that offset. For an offset like UTC+2, this doesn't *quite* + // work, since there are 57(!) matching zones (as of 2019-06-13) - but for less populated time zones + // this could work nicely. + var inputWithoutUtc = zoneStr.Replace("UTC", "").Replace("GMT", ""); + + var res = OffsetPattern.CreateWithInvariantCulture("+H").Parse(inputWithoutUtc); + if (!res.Success) res = OffsetPattern.CreateWithInvariantCulture("+H:mm").Parse(inputWithoutUtc); + + // If *this* didn't parse correctly, fuck it, bail. + if (!res.Success) return null; + var offset = res.Value; + + // To try to reduce the count, we go by locations from the 1970+ database instead of just the full database + // This elides regions that have been identical since 1970, omitting small distinctions due to Ancient History(tm). + var allZones = TzdbDateTimeZoneSource.Default.Zone1970Locations.Select(l => l.ZoneId).Distinct(); + matchingZones = allZones.Select(z => DateTimeZoneProviders.Tzdb.GetZoneOrNull(z)) + .Where(z => z.GetUtcOffset(SystemClock.Instance.GetCurrentInstant()) == offset).ToList(); + } + + // If we have a list of viable time zones, we ask the user which is correct. + + // If we only have one, return that one. + if (matchingZones.Count == 1) + return matchingZones.First(); + + // Otherwise, prompt and return! + return await ctx.Choose("There were multiple matches for your time zone query. Please select the region that matches you the closest:", matchingZones, + z => + { + if (TzdbDateTimeZoneSource.Default.Aliases.Contains(z.Id)) + return $"**{z.Id}**, {string.Join(", ", TzdbDateTimeZoneSource.Default.Aliases[z.Id])}"; + + return $"**{z.Id}**"; + }); + } + + public async Task SystemPing(Context ctx) + { + // note: this is here because this is also used in `pk;system ping`, which does not CheckSystem + ctx.CheckSystem(); + + // todo: move all the other config settings to this format + + String Response(bool isError, bool val) + => $"Reaction pings are {(isError ? "already" : "currently")} **{EnabledDisabled(val)}** for your system. " + + $"To {EnabledDisabled(!val)[..^1]} reaction pings, type `pk;config ping {EnabledDisabled(!val)[..^1]}`."; + + if (!ctx.HasNext()) + { + await ctx.Reply(Response(false, ctx.Config.PingsEnabled)); + return; + } + + var value = ctx.MatchToggle(true); + + if (ctx.Config.PingsEnabled == value) + await ctx.Reply(Response(true, ctx.Config.PingsEnabled)); + else + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { PingsEnabled = value }); + await ctx.Reply($"Reaction pings have now been {EnabledDisabled(value)}."); + } + } + + public async Task MemberDefaultPrivacy(Context ctx) + { + if (!ctx.HasNext()) + { + if (ctx.Config.MemberDefaultPrivate) { await ctx.Reply("Newly created members will currently have their privacy settings set to private. To change this, type `pk;config private member off`"); } + else { await ctx.Reply("Newly created members will currently have their privacy settings set to public. To automatically set new members' privacy settings to private, type `pk;config private member on`"); } + } + else + { + if (ctx.MatchToggle(false)) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { MemberDefaultPrivate = true }); + + await ctx.Reply("Newly created members will now have their privacy settings set to private."); + } + else + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { MemberDefaultPrivate = false }); + + await ctx.Reply("Newly created members will now have their privacy settings set to public."); + } + } + } + + public async Task GroupDefaultPrivacy(Context ctx) + { + if (!ctx.HasNext()) + { + if (ctx.Config.GroupDefaultPrivate) { await ctx.Reply("Newly created groups will currently have their privacy settings set to private. To change this, type `pk;config private group off`"); } + else { await ctx.Reply("Newly created groups will currently have their privacy settings set to public. To automatically set new groups' privacy settings to private, type `pk;config private group on`"); } + } + else + { + if (ctx.MatchToggle(false)) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { GroupDefaultPrivate = true }); + + await ctx.Reply("Newly created groups will now have their privacy settings set to private."); + } + else + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { GroupDefaultPrivate = false }); + + await ctx.Reply("Newly created groups will now have their privacy settings set to public."); + } + } + } + + public async Task ShowPrivateInfo(Context ctx) + { + if (!ctx.HasNext()) + { + if (ctx.Config.ShowPrivateInfo) await ctx.Reply("Private information is currently **shown** when looking up your own info. Use the `-public` flag to hide it."); + else await ctx.Reply("Private information is currently **hidden** when looking up your own info. Use the `-private` flag to show it."); + return; + } + + if (ctx.MatchToggle(true)) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ShowPrivateInfo = true }); + + await ctx.Reply("Private information will now be **shown** when looking up your own info. Use the `-public` flag to hide it."); + } + else + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ShowPrivateInfo = false }); + + await ctx.Reply("Private information will now be **hidden** when looking up your own info. Use the `-private` flag to show it."); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Fun.cs b/PluralKit.Bot/Commands/Fun.cs index 6ec22bb2..b1ab53e0 100644 --- a/PluralKit.Bot/Commands/Fun.cs +++ b/PluralKit.Bot/Commands/Fun.cs @@ -1,14 +1,53 @@ -using System.Threading.Tasks; +using Myriad.Builders; +using Myriad.Types; -namespace PluralKit.Bot +using NodaTime; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class Fun { - public class Fun + public Task Mn(Context ctx) => ctx.Reply("Gotta catch 'em all!"); + + public Task Fire(Context ctx) => + ctx.Reply("*A giant lightning bolt promptly erupts into a pillar of fire as it hits your opponent.*"); + + public Task Thunder(Context ctx) => + ctx.Reply("*A giant ball of lightning is conjured and fired directly at your opponent, vanquishing them.*"); + + public Task Freeze(Context ctx) => + ctx.Reply( + "*A giant crystal ball of ice is charged and hurled toward your opponent, bursting open and freezing them solid on contact.*"); + + public Task Starstorm(Context ctx) => + ctx.Reply("*Vibrant colours burst forth from the sky as meteors rain down upon your opponent.*"); + + public Task Flash(Context ctx) => + ctx.Reply( + "*A ball of green light appears above your head and flies towards your enemy, exploding on contact.*"); + + public Task Rool(Context ctx) => + ctx.Reply("*\"What the fuck is a Pokémon?\"*"); + + public Task Sus(Context ctx) => + ctx.Reply("\U0001F4EE"); + + public Task Error(Context ctx) { - public Task Mn(Context ctx) => ctx.Reply("Gotta catch 'em all!"); - public Task Fire(Context ctx) => ctx.Reply("*A giant lightning bolt promptly erupts into a pillar of fire as it hits your opponent.*"); - public Task Thunder(Context ctx) => ctx.Reply("*A giant ball of lightning is conjured and fired directly at your opponent, vanquishing them.*"); - public Task Freeze(Context ctx) => ctx.Reply("*A giant crystal ball of ice is charged and hurled toward your opponent, bursting open and freezing them solid on contact.*"); - public Task Starstorm(Context ctx) => ctx.Reply("*Vibrant colours burst forth from the sky as meteors rain down upon your opponent.*"); - public Task Flash(Context ctx) => ctx.Reply("*A ball of green light appears above your head and flies towards your enemy, exploding on contact.*"); + if (ctx.Match("message")) + return ctx.Reply("> **Error code:** `50f3c7b439d111ecab2023a5431fffbd`", new EmbedBuilder() + .Color(0xE74C3C) + .Title("Internal error occurred") + .Description( + "For support, please send the error code above in **#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)** with a description of what you were doing at the time.") + .Footer(new Embed.EmbedFooter("50f3c7b439d111ecab2023a5431fffbd")) + .Timestamp(SystemClock.Instance.GetCurrentInstant().ToDateTimeOffset().ToString("O")) + .Build() + ); + + return ctx.Reply( + $"{Emojis.Error} Unknown command {"error".AsCode()}. For a list of possible commands, see ."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/GroupMember.cs b/PluralKit.Bot/Commands/GroupMember.cs new file mode 100644 index 00000000..dd3b3e74 --- /dev/null +++ b/PluralKit.Bot/Commands/GroupMember.cs @@ -0,0 +1,152 @@ +using System.Text; + +using Humanizer; + +using Myriad.Builders; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class GroupMember +{ + public async Task AddRemoveGroups(Context ctx, PKMember target, Groups.AddRemoveOperation op) + { + ctx.CheckSystem().CheckOwnMember(target); + + var groups = (await ctx.ParseGroupList(ctx.System.Id)) + .Select(g => g.Id) + .Distinct() + .ToList(); + + var existingGroups = (await ctx.Repository.GetMemberGroups(target.Id).ToListAsync()) + .Select(g => g.Id) + .Distinct() + .ToList(); + + List toAction; + + if (op == Groups.AddRemoveOperation.Add) + { + toAction = groups + .Where(group => !existingGroups.Contains(group)) + .ToList(); + + await ctx.Repository.AddGroupsToMember(target.Id, toAction); + } + else if (op == Groups.AddRemoveOperation.Remove) + { + toAction = groups + .Where(group => existingGroups.Contains(group)) + .ToList(); + + await ctx.Repository.RemoveGroupsFromMember(target.Id, toAction); + } + else + { + return; // otherwise toAction "may be unassigned" + } + + await ctx.Reply(GroupMemberUtils.GenerateResponse(op, 1, groups.Count, toAction.Count, + groups.Count - toAction.Count)); + } + + public async Task ListMemberGroups(Context ctx, PKMember target) + { + var pctx = ctx.DirectLookupContextFor(target.System); + + var groups = await ctx.Repository.GetMemberGroups(target.Id) + .Where(g => g.Visibility.CanAccess(pctx)) + .OrderBy(g => (g.DisplayName ?? g.Name), StringComparer.InvariantCultureIgnoreCase) + .ToListAsync(); + + var description = ""; + var msg = ""; + + if (groups.Count == 0) + description = "This member has no groups."; + else + description = string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**")); + + if (pctx == LookupContext.ByOwner) + { + msg += + $"\n\nTo add this member to one or more groups, use `pk;m {target.Reference(ctx)} group add [group 2] [group 3...]`"; + if (groups.Count > 0) + msg += + $"\nTo remove this member from one or more groups, use `pk;m {target.Reference(ctx)} group remove [group 2] [group 3...]`"; + } + + await ctx.Reply(msg, new EmbedBuilder().Title($"{target.Name}'s groups").Description(description).Build()); + } + + public async Task AddRemoveMembers(Context ctx, PKGroup target, Groups.AddRemoveOperation op) + { + ctx.CheckOwnGroup(target); + + var members = (await ctx.ParseMemberList(ctx.System.Id)) + .Select(m => m.Id) + .Distinct() + .ToList(); + + var existingMembersInGroup = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System, + new DatabaseViewsExt.ListQueryOptions { GroupFilter = target.Id }))) + .Select(m => m.Id.Value) + .Distinct() + .ToHashSet(); + + List toAction; + + if (op == Groups.AddRemoveOperation.Add) + { + toAction = members + .Where(m => !existingMembersInGroup.Contains(m.Value)) + .ToList(); + await ctx.Repository.AddMembersToGroup(target.Id, toAction); + } + else if (op == Groups.AddRemoveOperation.Remove) + { + toAction = members + .Where(m => existingMembersInGroup.Contains(m.Value)) + .ToList(); + await ctx.Repository.RemoveMembersFromGroup(target.Id, toAction); + } + else + { + return; // otherwise toAction "may be undefined" + } + + await ctx.Reply(GroupMemberUtils.GenerateResponse(op, members.Count, 1, toAction.Count, + members.Count - toAction.Count)); + } + + public async Task ListGroupMembers(Context ctx, PKGroup target) + { + // see global system list for explanation of how privacy settings are used here + + var targetSystem = await GetGroupSystem(ctx, target); + ctx.CheckSystemPrivacy(targetSystem.Id, target.ListPrivacy); + + var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System)); + opts.GroupFilter = target.Id; + + var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.Hid}`) in "); + if (targetSystem.Name != null) + title.Append($"{targetSystem.Name} (`{targetSystem.Hid}`)"); + else + title.Append($"`{targetSystem.Hid}`"); + if (opts.Search != null) + title.Append($" matching **{opts.Search.Truncate(100)}**"); + + await ctx.RenderMemberList(ctx.LookupContextFor(target.System), target.System, title.ToString(), + target.Color, opts); + } + + private async Task GetGroupSystem(Context ctx, PKGroup target) + { + var system = ctx.System; + if (system?.Id == target.System) + return system; + return await ctx.Repository.GetSystem(target.System)!; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index f3f63fa5..4b92f369 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -1,439 +1,583 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; +using System.Text.RegularExpressions; -using Dapper; +using Myriad.Builders; +using Myriad.Types; -using DSharpPlus.Entities; - -using Humanizer; +using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Groups { - public class Groups + public enum AddRemoveOperation { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; + Add, + Remove + } - public Groups(IDatabase db, ModelRepository repo, EmbedService embeds) + private readonly HttpClient _client; + private readonly DispatchService _dispatch; + private readonly EmbedService _embeds; + + public Groups(EmbedService embeds, HttpClient client, + DispatchService dispatch) + { + _embeds = embeds; + _client = client; + _dispatch = dispatch; + } + + public async Task CreateGroup(Context ctx) + { + ctx.CheckSystem(); + + // Check group name length + var groupName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a group name."); + if (groupName.Length > Limits.MaxGroupNameLength) + throw new PKError($"Group name too long ({groupName.Length}/{Limits.MaxGroupNameLength} characters)."); + + // Check group cap + var existingGroupCount = await ctx.Repository.GetSystemGroupCount(ctx.System.Id); + var groupLimit = ctx.Config.GroupLimitOverride ?? Limits.MaxGroupCount; + if (existingGroupCount >= groupLimit) + throw new PKError( + $"System has reached the maximum number of groups ({groupLimit}). Please delete unused groups first in order to create new ones."); + + // Warn if there's already a group by this name + var existingGroup = await ctx.Repository.GetGroupByName(ctx.System.Id, groupName); + if (existingGroup != null) { - _db = db; - _repo = repo; - _embeds = embeds; + var msg = + $"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.Hid}`). Do you want to create another group with the same name?"; + if (!await ctx.PromptYesNo(msg, "Create")) + throw new PKError("Group creation cancelled."); } - public async Task CreateGroup(Context ctx) - { - ctx.CheckSystem(); - - // Check group name length - var groupName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a group name."); - if (groupName.Length > Limits.MaxGroupNameLength) - throw new PKError($"Group name too long ({groupName.Length}/{Limits.MaxGroupNameLength} characters)."); - - await using var conn = await _db.Obtain(); - - // Check group cap - var existingGroupCount = await conn.QuerySingleAsync("select count(*) from groups where system = @System", new { System = ctx.System.Id }); - var groupLimit = ctx.System.GroupLimitOverride ?? Limits.MaxGroupCount; - if (existingGroupCount >= groupLimit) - throw new PKError($"System has reached the maximum number of groups ({groupLimit}). Please delete unused groups first in order to create new ones."); + // todo: this is supposed to be a transaction, but it's not used in any useful way + // consider removing it? + using var conn = await ctx.Database.Obtain(); + var newGroup = await ctx.Repository.CreateGroup(ctx.System.Id, groupName); - // Warn if there's already a group by this name - var existingGroup = await _repo.GetGroupByName(conn, ctx.System.Id, groupName); - if (existingGroup != null) { - var msg = $"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.Hid}`). Do you want to create another group with the same name?"; - if (!await ctx.PromptYesNo(msg)) - throw new PKError("Group creation cancelled."); + var dispatchData = new JObject(); + dispatchData.Add("name", groupName); + + if (ctx.Config.GroupDefaultPrivate) + { + var patch = new GroupPatch().WithAllPrivacy(PrivacyLevel.Private); + await ctx.Repository.UpdateGroup(newGroup.Id, patch, conn); + dispatchData.Merge(patch.ToJson()); + } + + _ = _dispatch.Dispatch(newGroup.Id, new UpdateDispatchData + { + Event = DispatchEvent.CREATE_GROUP, + EventData = dispatchData + }); + + var reference = newGroup.Reference(ctx); + + var eb = new EmbedBuilder() + .Description( + $"Your new group, **{groupName}**, has been created, with the group ID **`{newGroup.Hid}`**.\nBelow are a couple of useful commands:") + .Field(new Embed.Field("View the group card", $"> pk;group **{reference}**")) + .Field(new Embed.Field("Add members to the group", + $"> pk;group **{reference}** add **MemberName**\n> pk;group **{reference}** add **Member1** **Member2** **Member3** (and so on...)")) + .Field(new Embed.Field("Set the description", + $"> pk;group **{reference}** description **This is my new group, and here is the description!**")) + .Field(new Embed.Field("Set the group icon", + $"> pk;group **{reference}** icon\n*(with an image attached)*")); + await ctx.Reply($"{Emojis.Success} Group created!", eb.Build()); + + if (existingGroupCount >= Limits.WarnThreshold(groupLimit)) + await ctx.Reply( + $"{Emojis.Warn} You are approaching the per-system group limit ({existingGroupCount} / {groupLimit} groups). Please review your group list for unused or duplicate groups."); + } + + public async Task RenameGroup(Context ctx, PKGroup target) + { + ctx.CheckOwnGroup(target); + + // Check group name length + var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new group name."); + if (newName.Length > Limits.MaxGroupNameLength) + throw new PKError( + $"New group name too long ({newName.Length}/{Limits.MaxMemberNameLength} characters)."); + + // Warn if there's already a group by this name + var existingGroup = await ctx.Repository.GetGroupByName(ctx.System.Id, newName); + if (existingGroup != null && existingGroup.Id != target.Id) + { + var msg = + $"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.Hid}`). Do you want to rename this group to that name too?"; + if (!await ctx.PromptYesNo(msg, "Rename")) + throw new PKError("Group rename cancelled."); + } + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Name = newName }); + + await ctx.Reply($"{Emojis.Success} Group name changed from **{target.Name}** to **{newName}**."); + } + + public async Task GroupDisplayName(Context ctx, PKGroup target) + { + var noDisplayNameSetMessage = "This group does not have a display name set."; + if (ctx.System?.Id == target.System) + noDisplayNameSetMessage += + $" To set one, type `pk;group {target.Reference(ctx)} displayname `."; + + // No perms check, display name isn't covered by member privacy + + if (ctx.MatchRaw()) + { + if (target.DisplayName == null) + await ctx.Reply(noDisplayNameSetMessage); + else + await ctx.Reply($"```\n{target.DisplayName}\n```"); + return; + } + + if (!ctx.HasNext(false)) + { + if (target.DisplayName == null) + { + await ctx.Reply(noDisplayNameSetMessage); } - - var newGroup = await _repo.CreateGroup(conn, ctx.System.Id, groupName); - - var eb = new DiscordEmbedBuilder() - .WithDescription($"Your new group, **{groupName}**, has been created, with the group ID **`{newGroup.Hid}`**.\nBelow are a couple of useful commands:") - .AddField("View the group card", $"> pk;group **{newGroup.Reference()}**") - .AddField("Add members to the group", $"> pk;group **{newGroup.Reference()}** add **MemberName**\n> pk;group **{newGroup.Reference()}** add **Member1** **Member2** **Member3** (and so on...)") - .AddField("Set the description", $"> pk;group **{newGroup.Reference()}** description **This is my new group, and here is the description!**") - .AddField("Set the group icon", $"> pk;group **{newGroup.Reference()}** icon\n*(with an image attached)*"); - await ctx.Reply($"{Emojis.Success} Group created!", eb.Build()); + else + { + var eb = new EmbedBuilder() + .Field(new Embed.Field("Name", target.Name)) + .Field(new Embed.Field("Display Name", target.DisplayName)); + + var reference = target.Reference(ctx); + + if (ctx.System?.Id == target.System) + eb.Description( + $"To change display name, type `pk;group {reference} displayname `." + + $"To clear it, type `pk;group {reference} displayname -clear`." + + $"To print the raw display name, type `pk;group {reference} displayname -raw`."); + + await ctx.Reply(embed: eb.Build()); + } + + return; } - public async Task RenameGroup(Context ctx, PKGroup target) + ctx.CheckOwnGroup(target); + + if (await ctx.MatchClear("this group's display name")) + { + var patch = new GroupPatch { DisplayName = Partial.Null() }; + await ctx.Repository.UpdateGroup(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Group display name cleared."); + if (target.NamePrivacy == PrivacyLevel.Private) + await ctx.Reply($"{Emojis.Warn} Since this group no longer has a display name set, their name privacy **can no longer take effect**."); + } + else + { + var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + + var patch = new GroupPatch { DisplayName = Partial.Present(newDisplayName) }; + await ctx.Repository.UpdateGroup(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Group display name changed."); + } + } + + public async Task GroupDescription(Context ctx, PKGroup target) + { + ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy); + + var noDescriptionSetMessage = "This group does not have a description set."; + if (ctx.System?.Id == target.System) + noDescriptionSetMessage += + $" To set one, type `pk;group {target.Reference(ctx)} description `."; + + if (ctx.MatchRaw()) + { + if (target.Description == null) + await ctx.Reply(noDescriptionSetMessage); + else + await ctx.Reply($"```\n{target.Description}\n```"); + return; + } + + if (!ctx.HasNext(false)) + { + if (target.Description == null) + await ctx.Reply(noDescriptionSetMessage); + else + await ctx.Reply(embed: new EmbedBuilder() + .Title("Group description") + .Description(target.Description) + .Field(new Embed.Field("\u200B", + $"To print the description with formatting, type `pk;group {target.Reference(ctx)} description -raw`." + + (ctx.System?.Id == target.System + ? $" To clear it, type `pk;group {target.Reference(ctx)} description -clear`." + : ""))) + .Build()); + return; + } + + ctx.CheckOwnGroup(target); + + if (await ctx.MatchClear("this group's description")) + { + var patch = new GroupPatch { Description = Partial.Null() }; + await ctx.Repository.UpdateGroup(target.Id, patch); + await ctx.Reply($"{Emojis.Success} Group description cleared."); + } + else + { + var description = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + if (description.IsLongerThan(Limits.MaxDescriptionLength)) + throw Errors.StringTooLongError("Description", description.Length, Limits.MaxDescriptionLength); + + var patch = new GroupPatch { Description = Partial.Present(description) }; + await ctx.Repository.UpdateGroup(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Group description changed."); + } + } + + public async Task GroupIcon(Context ctx, PKGroup target) + { + async Task ClearIcon() { ctx.CheckOwnGroup(target); - - // Check group name length - var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new group name."); - if (newName.Length > Limits.MaxGroupNameLength) - throw new PKError($"New group name too long ({newName.Length}/{Limits.MaxMemberNameLength} characters)."); - - await using var conn = await _db.Obtain(); - - // Warn if there's already a group by this name - var existingGroup = await _repo.GetGroupByName(conn, ctx.System.Id, newName); - if (existingGroup != null && existingGroup.Id != target.Id) { - var msg = $"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.Hid}`). Do you want to rename this member to that name too?"; - if (!await ctx.PromptYesNo(msg)) - throw new PKError("Group creation cancelled."); - } - await _repo.UpdateGroup(conn, target.Id, new GroupPatch {Name = newName}); - - await ctx.Reply($"{Emojis.Success} Group name changed from **{target.Name}** to **{newName}**."); + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = null }); + await ctx.Reply($"{Emojis.Success} Group icon cleared."); } - public async Task GroupDisplayName(Context ctx, PKGroup target) + async Task SetIcon(ParsedImage img) { - if (await ctx.MatchClear("this group's display name")) - { - ctx.CheckOwnGroup(target); - - var patch = new GroupPatch {DisplayName = Partial.Null()}; - await _db.Execute(conn => _repo.UpdateGroup(conn, target.Id, patch)); + ctx.CheckOwnGroup(target); - await ctx.Reply($"{Emojis.Success} Group display name cleared."); - } - else if (!ctx.HasNext()) + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.Url }); + + var msg = img.Source switch { - // No perms check, display name isn't covered by member privacy - var eb = new DiscordEmbedBuilder() - .AddField("Name", target.Name) - .AddField("Display Name", target.DisplayName ?? "*(none)*"); - - if (ctx.System?.Id == target.System) - eb.WithDescription($"To change display name, type `pk;group {target.Reference()} displayname `.\nTo clear it, type `pk;group {target.Reference()} displayname -clear`."); - + AvatarSource.User => + $"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set.", + AvatarSource.Url => $"{Emojis.Success} Group icon changed to the image at the given URL.", + AvatarSource.Attachment => + $"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working.", + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + async Task ShowIcon() + { + ctx.CheckSystemPrivacy(target.System, target.IconPrivacy); + + if ((target.Icon?.Trim() ?? "").Length > 0) + { + var eb = new EmbedBuilder() + .Title("Group icon") + .Image(new Embed.EmbedImage(target.Icon.TryGetCleanCdnUrl())); + + if (target.System == ctx.System?.Id) + eb.Description($"To clear, use `pk;group {target.Reference(ctx)} icon -clear`."); + await ctx.Reply(embed: eb.Build()); } else { - ctx.CheckOwnGroup(target); - - var newDisplayName = ctx.RemainderOrNull(); - - var patch = new GroupPatch {DisplayName = Partial.Present(newDisplayName)}; - await _db.Execute(conn => _repo.UpdateGroup(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Group display name changed."); - } - } - - public async Task GroupDescription(Context ctx, PKGroup target) - { - if (await ctx.MatchClear("this group's description")) - { - ctx.CheckOwnGroup(target); - - var patch = new GroupPatch {Description = Partial.Null()}; - await _db.Execute(conn => _repo.UpdateGroup(conn, target.Id, patch)); - await ctx.Reply($"{Emojis.Success} Group description cleared."); - } - else if (!ctx.HasNext()) - { - if (target.Description == null) - if (ctx.System?.Id == target.System) - await ctx.Reply($"This group does not have a description set. To set one, type `pk;group {target.Reference()} description `."); - else - await ctx.Reply("This group does not have a description set."); - else if (ctx.MatchFlag("r", "raw")) - await ctx.Reply($"```\n{target.Description}\n```"); - else - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("Group description") - .WithDescription(target.Description) - .AddField("\u200B", $"To print the description with formatting, type `pk;group {target.Reference()} description -raw`." - + (ctx.System?.Id == target.System ? $" To clear it, type `pk;group {target.Reference()} description -clear`." : "")) - .Build()); - } - else - { - ctx.CheckOwnGroup(target); - - var description = ctx.RemainderOrNull().NormalizeLineEndSpacing(); - if (description.IsLongerThan(Limits.MaxDescriptionLength)) - throw Errors.DescriptionTooLongError(description.Length); - - var patch = new GroupPatch {Description = Partial.Present(description)}; - await _db.Execute(conn => _repo.UpdateGroup(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Group description changed."); + throw new PKSyntaxError( + "This group does not have an icon set. Set one by attaching an image to this command, or by passing an image URL or @mention."); } } - public async Task GroupIcon(Context ctx, PKGroup target) - { - async Task ClearIcon() - { - ctx.CheckOwnGroup(target); - - await _db.Execute(c => _repo.UpdateGroup(c, target.Id, new GroupPatch {Icon = null})); - await ctx.Reply($"{Emojis.Success} Group icon cleared."); - } + if (await ctx.MatchClear("this group's icon")) + await ClearIcon(); + else if (await ctx.MatchImage() is { } img) + await SetIcon(img); + else + await ShowIcon(); + } - async Task SetIcon(ParsedImage img) - { - ctx.CheckOwnGroup(target); - - await AvatarUtils.VerifyAvatarOrThrow(img.Url); - - await _db.Execute(c => _repo.UpdateGroup(c, target.Id, new GroupPatch {Icon = img.Url})); - - var msg = img.Source switch - { - AvatarSource.User => $"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set.", - AvatarSource.Url => $"{Emojis.Success} Group icon changed to the image at the given URL.", - AvatarSource.Attachment => $"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working.", - _ => throw new ArgumentOutOfRangeException() - }; - - // The attachment's already right there, no need to preview it. - var hasEmbed = img.Source != AvatarSource.Attachment; - await (hasEmbed - ? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(img.Url).Build()) - : ctx.Reply(msg)); - } - - async Task ShowIcon() - { - if ((target.Icon?.Trim() ?? "").Length > 0) - { - var eb = new DiscordEmbedBuilder() - .WithTitle("Group icon") - .WithImageUrl(target.Icon); - - if (target.System == ctx.System?.Id) - { - eb.WithDescription($"To clear, use `pk;group {target.Reference()} icon -clear`."); - } - - await ctx.Reply(embed: eb.Build()); - } - else - throw new PKSyntaxError("This group does not have an icon set. Set one by attaching an image to this command, or by passing an image URL or @mention."); - } - - if (await ctx.MatchClear("this group's icon")) - await ClearIcon(); - else if (await ctx.MatchImage() is {} img) - await SetIcon(img); - else - await ShowIcon(); - } - - public async Task ListSystemGroups(Context ctx, PKSystem system) - { - if (system == null) - { - ctx.CheckSystem(); - system = ctx.System; - } - - ctx.CheckSystemPrivacy(system, system.GroupListPrivacy); - - // TODO: integrate with the normal "search" system - await using var conn = await _db.Obtain(); - - var pctx = LookupContext.ByNonOwner; - if (ctx.MatchFlag("a", "all")) - { - if (system.Id == ctx.System.Id) - pctx = LookupContext.ByOwner; - else - throw new PKError("You do not have permission to access this information."); - } - - var groups = (await conn.QueryGroupList(system.Id)) - .Where(g => g.Visibility.CanAccess(pctx)) - .OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase) - .ToList(); - - if (groups.Count == 0) - { - if (system.Id == ctx.System?.Id) - await ctx.Reply("This system has no groups. To create one, use the command `pk;group new `."); - else - await ctx.Reply("This system has no groups."); - - return; - } - - var title = system.Name != null ? $"Groups of {system.Name} (`{system.Hid}`)" : $"Groups of `{system.Hid}`"; - await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, 25, title, Renderer); - - Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) - { - eb.WithSimpleLineContent(page.Select(g => - { - if (g.DisplayName != null) - return $"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({g.DisplayName.EscapeMarkdown()}) ({"member".ToQuantity(g.MemberCount)})"; - else - return $"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({"member".ToQuantity(g.MemberCount)})"; - })); - eb.WithFooter($"{groups.Count} total."); - return Task.CompletedTask; - } - } - - public async Task ShowGroupCard(Context ctx, PKGroup target) - { - await using var conn = await _db.Obtain(); - var system = await GetGroupSystem(ctx, target, conn); - await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, system, target)); - } - - public async Task AddRemoveMembers(Context ctx, PKGroup target, AddRemoveOperation op) + public async Task GroupBannerImage(Context ctx, PKGroup target) + { + async Task ClearBannerImage() { ctx.CheckOwnGroup(target); - var members = (await ctx.ParseMemberList(ctx.System.Id)) - .Select(m => m.Id) - .Distinct() - .ToList(); - - await using var conn = await _db.Obtain(); - - var existingMembersInGroup = (await conn.QueryMemberList(target.System, - new DatabaseViewsExt.MemberListQueryOptions {GroupFilter = target.Id})) - .Select(m => m.Id.Value) - .Distinct() - .ToHashSet(); - - List toAction; - - if (op == AddRemoveOperation.Add) - { - toAction = members - .Where(m => !existingMembersInGroup.Contains(m.Value)) - .ToList(); - await _repo.AddMembersToGroup(conn, target.Id, toAction); - } - else if (op == AddRemoveOperation.Remove) - { - toAction = members - .Where(m => existingMembersInGroup.Contains(m.Value)) - .ToList(); - await _repo.RemoveMembersFromGroup(conn, target.Id, toAction); - } - else return; // otherwise toAction "may be undefined" - - await ctx.Reply(MiscUtils.GroupAddRemoveResponse(members, toAction, op)); + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null }); + await ctx.Reply($"{Emojis.Success} Group banner image cleared."); } - public async Task ListGroupMembers(Context ctx, PKGroup target) - { - await using var conn = await _db.Obtain(); - - var targetSystem = await GetGroupSystem(ctx, target, conn); - ctx.CheckSystemPrivacy(targetSystem, target.ListPrivacy); - - var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target.System)); - opts.GroupFilter = target.Id; - - var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.Hid}`) in "); - if (targetSystem.Name != null) - title.Append($"{targetSystem.Name} (`{targetSystem.Hid}`)"); - else - title.Append($"`{targetSystem.Hid}`"); - if (opts.Search != null) - title.Append($" matching **{opts.Search}**"); - - await ctx.RenderMemberList(ctx.LookupContextFor(target.System), _db, target.System, title.ToString(), opts); - } - - public enum AddRemoveOperation - { - Add, - Remove - } - - public async Task GroupPrivacy(Context ctx, PKGroup target, PrivacyLevel? newValueFromCommand) - { - ctx.CheckSystem().CheckOwnGroup(target); - // Display privacy settings - if (!ctx.HasNext() && newValueFromCommand == null) - { - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle($"Current privacy settings for {target.Name}") - .AddField("Description", target.DescriptionPrivacy.Explanation()) - .AddField("Icon", target.IconPrivacy.Explanation()) - .AddField("Member list", target.ListPrivacy.Explanation()) - .AddField("Visibility", target.Visibility.Explanation()) - .WithDescription($"To edit privacy settings, use the command:\n> pk;group **{target.Reference()}** privacy **** ****\n\n- `subject` is one of `description`, `icon`, `members`, `visibility`, or `all`\n- `level` is either `public` or `private`.") - .Build()); - return; - } - - async Task SetAll(PrivacyLevel level) - { - await _db.Execute(c => _repo.UpdateGroup(c, target.Id, new GroupPatch().WithAllPrivacy(level))); - - if (level == PrivacyLevel.Private) - await ctx.Reply($"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the group card."); - else - await ctx.Reply($"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the group card."); - } - - async Task SetLevel(GroupPrivacySubject subject, PrivacyLevel level) - { - await _db.Execute(c => _repo.UpdateGroup(c, target.Id, new GroupPatch().WithPrivacy(subject, level))); - - var subjectName = subject switch - { - GroupPrivacySubject.Description => "description privacy", - GroupPrivacySubject.Icon => "icon privacy", - GroupPrivacySubject.List => "member list", - GroupPrivacySubject.Visibility => "visibility", - _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") - }; - - var explanation = (subject, level) switch - { - (GroupPrivacySubject.Description, PrivacyLevel.Private) => "This group's description is now hidden from other systems.", - (GroupPrivacySubject.Icon, PrivacyLevel.Private) => "This group's icon is now hidden from other systems.", - (GroupPrivacySubject.Visibility, PrivacyLevel.Private) => "This group is now hidden from group lists and member cards.", - (GroupPrivacySubject.List, PrivacyLevel.Private) => "This group's member list is now hidden from other systems.", - - (GroupPrivacySubject.Description, PrivacyLevel.Public) => "This group's description is no longer hidden from other systems.", - (GroupPrivacySubject.Icon, PrivacyLevel.Public) => "This group's icon is no longer hidden from other systems.", - (GroupPrivacySubject.Visibility, PrivacyLevel.Public) => "This group is no longer hidden from group lists and member cards.", - (GroupPrivacySubject.List, PrivacyLevel.Public) => "This group's member list is no longer hidden from other systems.", - - _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") - }; - - await ctx.Reply($"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"); - } - - if (ctx.Match("all") || newValueFromCommand != null) - await SetAll(newValueFromCommand ?? ctx.PopPrivacyLevel()); - else - await SetLevel(ctx.PopGroupPrivacySubject(), ctx.PopPrivacyLevel()); - } - - public async Task DeleteGroup(Context ctx, PKGroup target) + async Task SetBannerImage(ParsedImage img) { ctx.CheckOwnGroup(target); - await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete this group? If so, reply to this message with the group's ID (`{target.Hid}`).\n**Note: this action is permanent.**"); - if (!await ctx.ConfirmWithReply(target.Hid)) - throw new PKError($"Group deletion cancelled. Note that you must reply with your group ID (`{target.Hid}`) *verbatim*."); + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true); - await _db.Execute(conn => _repo.DeleteGroup(conn, target.Id)); - - await ctx.Reply($"{Emojis.Success} Group deleted."); + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.Url }); + + var msg = img.Source switch + { + AvatarSource.Url => $"{Emojis.Success} Group banner image changed to the image at the given URL.", + AvatarSource.Attachment => + $"{Emojis.Success} Group banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", + AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); } - private async Task GetGroupSystem(Context ctx, PKGroup target, IPKConnection conn) + async Task ShowBannerImage() { - var system = ctx.System; - if (system?.Id == target.System) - return system; - return await _repo.GetSystem(conn, target.System)!; + ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy); + + if ((target.BannerImage?.Trim() ?? "").Length > 0) + { + var eb = new EmbedBuilder() + .Title("Group banner image") + .Image(new Embed.EmbedImage(target.BannerImage)); + + if (target.System == ctx.System?.Id) + eb.Description($"To clear, use `pk;group {target.Reference(ctx)} banner clear`."); + + await ctx.Reply(embed: eb.Build()); + } + else + { + throw new PKSyntaxError( + "This group does not have a banner image set. Set one by attaching an image to this command, or by passing an image URL or @mention."); + } + } + + if (await ctx.MatchClear("this group's banner image")) + await ClearBannerImage(); + else if (await ctx.MatchImage() is { } img) + await SetBannerImage(img); + else + await ShowBannerImage(); + } + + public async Task GroupColor(Context ctx, PKGroup target) + { + var color = ctx.RemainderOrNull(); + if (await ctx.MatchClear()) + { + ctx.CheckOwnGroup(target); + + var patch = new GroupPatch { Color = Partial.Null() }; + await ctx.Repository.UpdateGroup(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Group color cleared."); + } + else if (!ctx.HasNext()) + { + if (target.Color == null) + if (ctx.System?.Id == target.System) + await ctx.Reply( + $"This group does not have a color set. To set one, type `pk;group {target.Reference(ctx)} color `."); + else + await ctx.Reply("This group does not have a color set."); + else + await ctx.Reply(embed: new EmbedBuilder() + .Title("Group color") + .Color(target.Color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20")) + .Description($"This group's color is **#{target.Color}**." + + (ctx.System?.Id == target.System + ? $" To clear it, type `pk;group {target.Reference(ctx)} color -clear`." + : "")) + .Build()); + } + else + { + ctx.CheckOwnGroup(target); + + if (color.StartsWith("#")) color = color.Substring(1); + if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); + + var patch = new GroupPatch { Color = Partial.Present(color.ToLowerInvariant()) }; + await ctx.Repository.UpdateGroup(target.Id, patch); + + await ctx.Reply(embed: new EmbedBuilder() + .Title($"{Emojis.Success} Group color changed.") + .Color(color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{color}/?text=%20")) + .Build()); } } + + public async Task ListSystemGroups(Context ctx, PKSystem system) + { + if (system == null) + { + ctx.CheckSystem(); + system = ctx.System; + } + + ctx.CheckSystemPrivacy(system.Id, system.GroupListPrivacy); + + // explanation of privacy lookup here: + // - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list) + // - RenderGroupList checks the indivual privacy for each member (NameFor, etc) + // the own system is always allowed to look up their list + var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(system.Id)); + await ctx.RenderGroupList( + ctx.LookupContextFor(system.Id), + system.Id, + GetEmbedTitle(system, opts), + system.Color, + opts + ); + } + + private string GetEmbedTitle(PKSystem target, ListOptions opts) + { + var title = new StringBuilder("Groups of "); + + if (target.Name != null) + title.Append($"{target.Name} (`{target.Hid}`)"); + else + title.Append($"`{target.Hid}`"); + + if (opts.Search != null) + title.Append($" matching **{opts.Search}**"); + + return title.ToString(); + } + + public async Task ShowGroupCard(Context ctx, PKGroup target) + { + var system = await GetGroupSystem(ctx, target); + await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, system, target)); + } + + public async Task GroupPrivacy(Context ctx, PKGroup target, PrivacyLevel? newValueFromCommand) + { + ctx.CheckSystem().CheckOwnGroup(target); + // Display privacy settings + if (!ctx.HasNext() && newValueFromCommand == null) + { + await ctx.Reply(embed: new EmbedBuilder() + .Title($"Current privacy settings for {target.Name}") + .Field(new Embed.Field("Name", target.NamePrivacy.Explanation())) + .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) + .Field(new Embed.Field("Icon", target.IconPrivacy.Explanation())) + .Field(new Embed.Field("Member list", target.ListPrivacy.Explanation())) + .Field(new Embed.Field("Metadata (creation date)", target.MetadataPrivacy.Explanation())) + .Field(new Embed.Field("Visibility", target.Visibility.Explanation())) + .Description( + $"To edit privacy settings, use the command:\n> pk;group **{target.Reference(ctx)}** privacy **** ****\n\n- `subject` is one of `name`, `description`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + .Build()); + return; + } + + async Task SetAll(PrivacyLevel level) + { + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithAllPrivacy(level)); + + if (level == PrivacyLevel.Private) + await ctx.Reply( + $"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the group card."); + else + await ctx.Reply( + $"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the group card."); + } + + async Task SetLevel(GroupPrivacySubject subject, PrivacyLevel level) + { + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithPrivacy(subject, level)); + + var subjectName = subject switch + { + GroupPrivacySubject.Name => "name privacy", + GroupPrivacySubject.Description => "description privacy", + GroupPrivacySubject.Icon => "icon privacy", + GroupPrivacySubject.List => "member list", + GroupPrivacySubject.Metadata => "metadata", + GroupPrivacySubject.Visibility => "visibility", + _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") + }; + + var explanation = (subject, level) switch + { + (GroupPrivacySubject.Name, PrivacyLevel.Private) => + "This group's name is now hidden from other systems, and will be replaced by the group's display name.", + (GroupPrivacySubject.Description, PrivacyLevel.Private) => + "This group's description is now hidden from other systems.", + (GroupPrivacySubject.Icon, PrivacyLevel.Private) => + "This group's icon is now hidden from other systems.", + (GroupPrivacySubject.Visibility, PrivacyLevel.Private) => + "This group is now hidden from group lists and member cards.", + (GroupPrivacySubject.Metadata, PrivacyLevel.Private) => + "This group's metadata (eg. creation date) is now hidden from other systems.", + (GroupPrivacySubject.List, PrivacyLevel.Private) => + "This group's member list is now hidden from other systems.", + + (GroupPrivacySubject.Name, PrivacyLevel.Public) => + "This group's name is no longer hidden from other systems.", + (GroupPrivacySubject.Description, PrivacyLevel.Public) => + "This group's description is no longer hidden from other systems.", + (GroupPrivacySubject.Icon, PrivacyLevel.Public) => + "This group's icon is no longer hidden from other systems.", + (GroupPrivacySubject.Visibility, PrivacyLevel.Public) => + "This group is no longer hidden from group lists and member cards.", + (GroupPrivacySubject.Metadata, PrivacyLevel.Public) => + "This group's metadata (eg. creation date) is no longer hidden from other systems.", + (GroupPrivacySubject.List, PrivacyLevel.Public) => + "This group's member list is no longer hidden from other systems.", + + _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") + }; + + await ctx.Reply( + $"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"); + + if (subject == GroupPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null) + await ctx.Reply( + $"{Emojis.Warn} This group does not have a display name set, and name privacy **will not take effect**."); + } + + if (ctx.Match("all") || newValueFromCommand != null) + await SetAll(newValueFromCommand ?? ctx.PopPrivacyLevel()); + else + await SetLevel(ctx.PopGroupPrivacySubject(), ctx.PopPrivacyLevel()); + } + + public async Task DeleteGroup(Context ctx, PKGroup target) + { + ctx.CheckOwnGroup(target); + + await ctx.Reply( + $"{Emojis.Warn} Are you sure you want to delete this group? If so, reply to this message with the group's ID (`{target.Hid}`).\n**Note: this action is permanent.**"); + if (!await ctx.ConfirmWithReply(target.Hid)) + throw new PKError( + $"Group deletion cancelled. Note that you must reply with your group ID (`{target.Hid}`) *verbatim*."); + + await ctx.Repository.DeleteGroup(target.Id); + + await ctx.Reply($"{Emojis.Success} Group deleted."); + } + + private async Task GetGroupSystem(Context ctx, PKGroup target) + { + var system = ctx.System; + if (system?.Id == target.System) + return system; + return await ctx.Repository.GetSystem(target.System)!; + } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index 20b41c85..f8289773 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -1,32 +1,80 @@ -using System.Threading.Tasks; - -using DSharpPlus.Entities; +using Myriad.Builders; +using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot -{ - public class Help - { - public async Task HelpRoot(Context ctx) - { - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("PluralKit") - .WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.") - .AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.") - .AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation.") - .AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the Getting Started guide](https://pluralkit.me/start) for more information.") - .AddField("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nReact with {Emojis.Bell} on a proxied message to \"ping\" the sender\nType **`pk;invite`** to get a link to invite this bot to your own server!") - .AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") - .AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78") - .WithFooter($"By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/") - .WithColor(DiscordUtils.Blue) - .Build()); - } +namespace PluralKit.Bot; - public async Task Explain(Context ctx) +public class Help +{ + private static Embed helpEmbed = new() + { + Title = "PluralKit", + Description = "PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.", + Fields = new[] { - await ctx.Reply("> **About PluralKit**\nPluralKit detects messages enclosed in specific tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using Discord webhooks.\n\nThis is useful for multiple people sharing one body (aka. *systems*), people who wish to role-play as different characters without having multiple Discord accounts, or anyone else who may want to post messages under a different identity from the same Discord account.\n\nDue to Discord limitations, these messages will show up with the `[BOT]` tag - however, they are not bots."); - } - } + new Embed.Field + ( + "What is this for? What are systems?", + "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks." + + " This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account." + ), + new + ( + "Why are people's names saying [BOT] next to them?", + "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation." + ), + new + ( + "How do I get started?", + String.Join("\n", new[] + { + "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):", + "**1**. `pk;system new` - Create a system (if you haven't already)", + "**2**. `pk;member add John` - Add a new member to your system", + "**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags", + "**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.", + "**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.", + "\nSee [the Getting Started guide](https://pluralkit.me/start) for more information." + }) + ), + new + ( + "Useful tips", + String.Join("\n", new[] { + $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)", + $"React with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)", + $"React with {Emojis.Bell} on a proxied message to \"ping\" the sender", + "Type **`pk;invite`** to get a link to invite this bot to your own server!" + }) + ), + new + ( + "More information", + String.Join("\n", new[] { + "For a full list of commands, see [the command list](https://pluralkit.me/commands).", + "For a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).", + "If you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there." + }) + ), + new + ( + "Support server", + "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78" + ) + }, + Footer = new("By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/"), + Color = DiscordUtils.Blue, + }; + + public Task HelpRoot(Context ctx) => ctx.Reply(embed: helpEmbed); + + private static string explanation = String.Join("\n\n", new[] + { + "> **About PluralKit**\nPluralKit detects messages enclosed in specific tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using Discord webhooks.", + "This is useful for multiple people sharing one body (aka. *systems*), people who wish to role-play as different characters without having multiple Discord accounts, or anyone else who may want to post messages under a different identity from the same Discord account.", + "Due to Discord limitations, these messages will show up with the `[BOT]` tag - however, they are not bots." + }); + + public Task Explain(Context ctx) => ctx.Reply(explanation); } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index 9003d1dc..36722357 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -1,159 +1,132 @@ -using System; -using System.IO; -using System.Linq; -using System.Net.Http; using System.Text; -using System.Threading.Tasks; + +using Myriad.Rest.Exceptions; +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; using Newtonsoft.Json; -using DSharpPlus.Exceptions; -using DSharpPlus.Entities; - using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class ImportExport { - public class ImportExport + private readonly HttpClient _client; + private readonly DataFileService _dataFiles; + private readonly PrivateChannelService _dmCache; + + private readonly JsonSerializerSettings _settings = new() { - private readonly DataFileService _dataFiles; - private readonly JsonSerializerSettings _settings = new JsonSerializerSettings - { - // Otherwise it'll mess up/reformat the ISO strings for ???some??? reason >.> - DateParseHandling = DateParseHandling.None - }; - - public ImportExport(DataFileService dataFiles) - { - _dataFiles = dataFiles; - } + // Otherwise it'll mess up/reformat the ISO strings for ???some??? reason >.> + DateParseHandling = DateParseHandling.None + }; - public async Task Import(Context ctx) - { - var url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url; - if (url == null) throw Errors.NoImportFilePassed; + public ImportExport(DataFileService dataFiles, HttpClient client, PrivateChannelService dmCache) + { + _dataFiles = dataFiles; + _client = client; + _dmCache = dmCache; + } - await ctx.BusyIndicator(async () => + public async Task Import(Context ctx) + { + var url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url; + if (url == null) throw Errors.NoImportFilePassed; + + await ctx.BusyIndicator(async () => + { + JObject data; + try { - using (var client = new HttpClient()) - { - HttpResponseMessage response; - try - { - response = await client.GetAsync(url); - } - catch (InvalidOperationException) - { - // Invalid URL throws this, we just error back out - throw Errors.InvalidImportFile; - } - - if (!response.IsSuccessStatusCode) - throw Errors.InvalidImportFile; - - DataFileSystem data; - try - { - var json = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync(), _settings); - data = await LoadSystem(ctx, json); - } - catch (JsonException) - { - throw Errors.InvalidImportFile; - } - - if (!data.Valid) - throw Errors.InvalidImportFile; - - if (data.LinkedAccounts != null && !data.LinkedAccounts.Contains(ctx.Author.Id)) - { - var msg = $"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?"; - if (!await ctx.PromptYesNo(msg)) throw Errors.ImportCancelled; - } - - // If passed system is null, it'll create a new one - // (and that's okay!) - var result = await _dataFiles.ImportSystem(data, ctx.System, ctx.Author.Id); - if (!result.Success) - await ctx.Reply($"{Emojis.Error} The provided system profile could not be imported. {result.Message}"); - else if (ctx.System == null) - { - // We didn't have a system prior to importing, so give them the new system's ID - await ctx.Reply($"{Emojis.Success} PluralKit has created a system for you based on the given file. Your system ID is `{result.System.Hid}`. Type `pk;system` for more information."); - } - else - { - // We already had a system, so show them what changed - await ctx.Reply($"{Emojis.Success} Updated {result.ModifiedNames.Count} members, created {result.AddedNames.Count} members. Type `pk;system list` to check!"); - } - } - }); - } - - private async Task LoadSystem(Context ctx, JObject json) - { - if (json.ContainsKey("tuppers")) - return await ImportFromTupperbox(ctx, json); - - return json.ToObject(); - } - - private async Task ImportFromTupperbox(Context ctx, JObject json) - { - var tupperbox = json.ToObject(); - if (!tupperbox.Valid) + var response = await _client.GetAsync(url); + if (!response.IsSuccessStatusCode) + throw Errors.InvalidImportFile; + data = JsonConvert.DeserializeObject( + await response.Content.ReadAsStringAsync(), + _settings + ); + if (data == null) + throw Errors.InvalidImportFile; + } + catch (InvalidOperationException) + { + // Invalid URL throws this, we just error back out throw Errors.InvalidImportFile; - - var res = tupperbox.ToPluralKit(); - if (res.HadGroups || res.HadIndividualTags) + } + catch (JsonException) { - var issueStr = - $"{Emojis.Warn} The following potential issues were detected converting your Tupperbox input file:"; - if (res.HadGroups) - issueStr += "\n- PluralKit does not support member groups. Members will be imported without groups."; - if (res.HadIndividualTags) - issueStr += "\n- PluralKit does not support per-member system tags. Since you had multiple members with distinct tags, those tags will be applied to the members' *display names*/nicknames instead."; + throw Errors.InvalidImportFile; + } - var msg = $"{issueStr}\n\nDo you want to proceed with the import?"; - if (!await ctx.PromptYesNo(msg)) + async Task ConfirmImport(string message) + { + var msg = $"{message}\n\nDo you want to proceed with the import?"; + if (!await ctx.PromptYesNo(msg, "Proceed")) throw Errors.ImportCancelled; } - return res.System; - } - - public async Task Export(Context ctx) - { - ctx.CheckSystem(); - - var json = await ctx.BusyIndicator(async () => + if (data.ContainsKey("accounts") + && data.Value("accounts").Type != JTokenType.Null + && data.Value("accounts").Contains(ctx.Author.Id.ToString())) { - // Make the actual data file - var data = await _dataFiles.ExportSystem(ctx.System); - return JsonConvert.SerializeObject(data, Formatting.None); - }); - - - // Send it as a Discord attachment *in DMs* - var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - try - { - var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); - var msg = await dm.SendFileAsync("system.json", stream, $"{Emojis.Success} Here you go!"); - await dm.SendMessageAsync($"<{msg.Attachments[0].Url}>"); - - // If the original message wasn't posted in DMs, send a public reminder - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Success} Check your DMs!"); + var msg = $"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?"; + if (!await ctx.PromptYesNo(msg, "Import")) throw Errors.ImportCancelled; } - catch (UnauthorizedException) - { - // If user has DMs closed, tell 'em to open them + + var result = await _dataFiles.ImportSystem(ctx.Author.Id, ctx.System, data, ConfirmImport); + if (!result.Success) + if (result.Message == null) + throw Errors.InvalidImportFile; + else + await ctx.Reply( + $"{Emojis.Error} The provided system profile could not be imported: {result.Message}"); + else if (ctx.System == null) + // We didn't have a system prior to importing, so give them the new system's ID await ctx.Reply( - $"{Emojis.Error} Could not send the data file in your DMs. Do you have DMs closed?"); - } + $"{Emojis.Success} PluralKit has created a system for you based on the given file. Your system ID is `{result.CreatedSystem}`. Type `pk;system` for more information."); + else + // We already had a system, so show them what changed + await ctx.Reply( + $"{Emojis.Success} Updated {result.Modified} members, created {result.Added} members. Type `pk;system list` to check!"); + }); + } + + public async Task Export(Context ctx) + { + ctx.CheckSystem(); + + var json = await ctx.BusyIndicator(async () => + { + // Make the actual data file + var data = await _dataFiles.ExportSystem(ctx.System); + return JsonConvert.SerializeObject(data, Formatting.None); + }); + + + // Send it as a Discord attachment *in DMs* + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + try + { + var dm = await _dmCache.GetOrCreateDmChannel(ctx.Author.Id); + + var msg = await ctx.Rest.CreateMessage(dm, + new MessageRequest { Content = $"{Emojis.Success} Here you go!" }, + new[] { new MultipartFile("system.json", stream, null) }); + await ctx.Rest.CreateMessage(dm, new MessageRequest { Content = $"<{msg.Attachments[0].Url}>" }); + + // If the original message wasn't posted in DMs, send a public reminder + if (ctx.Channel.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Success} Check your DMs!"); + } + catch (ForbiddenException) + { + // If user has DMs closed, tell 'em to open them + await ctx.Reply( + $"{Emojis.Error} Could not send the data file in your DMs. Do you have DMs closed?"); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Lists/ContextListExt.cs b/PluralKit.Bot/Commands/Lists/ContextListExt.cs index 5c9da71c..ef820155 100644 --- a/PluralKit.Bot/Commands/Lists/ContextListExt.cs +++ b/PluralKit.Bot/Commands/Lists/ContextListExt.cs @@ -1,170 +1,389 @@ -using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; - -using DSharpPlus.Entities; using Humanizer; -using NodaTime; +using Myriad.Builders; +using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public static class ContextListExt { - public static class ContextListExt + public static ListOptions ParseListOptions(this Context ctx, LookupContext lookupCtx) { - public static MemberListOptions ParseMemberListOptions(this Context ctx, LookupContext lookupCtx) + var p = new ListOptions(); + + // Short or long list? (parse this first, as it can potentially take a positional argument) + var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full"); + p.Type = isFull ? ListType.Long : ListType.Short; + + // Search query + if (ctx.HasNext()) + p.Search = ctx.RemainderOrNull(); + + // Include description in search? + if (ctx.MatchFlag( + "search-description", + "filter-description", + "in-description", + "sd", + "description", + "desc" + )) + p.SearchDescription = true; + + // Sort property (default is by name, but adding a flag anyway, 'cause why not) + if (ctx.MatchFlag("by-name", "bn")) p.SortProperty = SortProperty.Name; + if (ctx.MatchFlag("by-display-name", "bdn")) p.SortProperty = SortProperty.DisplayName; + if (ctx.MatchFlag("by-id", "bid")) p.SortProperty = SortProperty.Hid; + if (ctx.MatchFlag("by-message-count", "bmc")) p.SortProperty = SortProperty.MessageCount; + if (ctx.MatchFlag("by-created", "bc", "bcd")) p.SortProperty = SortProperty.CreationDate; + if (ctx.MatchFlag("by-last-fronted", "by-last-front", "by-last-switch", "blf", "bls")) + p.SortProperty = SortProperty.LastSwitch; + if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage; + if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate; + if (ctx.MatchFlag("random")) p.SortProperty = SortProperty.Random; + + // Sort reverse? + if (ctx.MatchFlag("r", "rev", "reverse")) + p.Reverse = true; + + // Privacy filter (default is public only) + if (ctx.MatchFlag("a", "all")) p.PrivacyFilter = null; + if (ctx.MatchFlag("private-only", "po")) p.PrivacyFilter = PrivacyLevel.Private; + + // PERM CHECK: If we're trying to access non-public members of another system, error + if (p.PrivacyFilter != PrivacyLevel.Public && lookupCtx != LookupContext.ByOwner) + // TODO: should this just return null instead of throwing or something? >.> + throw Errors.NotOwnInfo; + + // Additional fields to include in the search results + if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf")) + p.IncludeLastSwitch = true; + if (ctx.MatchFlag("with-last-message", "with-last-proxy", "wlm", "wlp")) + throw new PKError("Sorting by last message is temporarily disabled due to database issues, sorry."); + // p.IncludeLastMessage = true; + if (ctx.MatchFlag("with-message-count", "wmc")) + p.IncludeMessageCount = true; + if (ctx.MatchFlag("with-created", "wc")) + p.IncludeCreated = true; + if (ctx.MatchFlag("with-avatar", "with-image", "with-icon", "wa", "wi", "ia", "ii", "img")) + p.IncludeAvatar = true; + if (ctx.MatchFlag("with-pronouns", "wp", "wprns")) + p.IncludePronouns = true; + if (ctx.MatchFlag("with-displayname", "wdn")) + p.IncludeDisplayName = true; + + // Always show the sort property, too + if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true; + if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage = true; + if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true; + if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true; + + // Done! + return p; + } + + public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx, + SystemId system, string embedTitle, string color, ListOptions opts) + { + // We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime + // We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout) + var members = (await ctx.Database.Execute(conn => conn.QueryMemberList(system, opts.ToQueryOptions()))) + .SortByMemberListOptions(opts, lookupCtx) + .ToList(); + + var itemsPerPage = opts.Type == ListType.Short ? 25 : 5; + await ctx.Paginate(members.ToAsyncEnumerable(), members.Count, itemsPerPage, embedTitle, color, Renderer); + + // Base renderer, dispatches based on type + Task Renderer(EmbedBuilder eb, IEnumerable page) { - var p = new MemberListOptions(); - - // Short or long list? (parse this first, as it can potentially take a positional argument) - var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full"); - p.Type = isFull ? ListType.Long : ListType.Short; - - // Search query - if (ctx.HasNext()) - p.Search = ctx.RemainderOrNull(); - - // Include description in search? - if (ctx.MatchFlag("search-description", "filter-description", "in-description", "sd", "description", "desc")) - p.SearchDescription = true; + // Add a global footer with the filter/sort string + result count + eb.Footer(new Embed.EmbedFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}.")); - // Sort property (default is by name, but adding a flag anyway, 'cause why not) - if (ctx.MatchFlag("by-name", "bn")) p.SortProperty = SortProperty.Name; - if (ctx.MatchFlag("by-display-name", "bdn")) p.SortProperty = SortProperty.DisplayName; - if (ctx.MatchFlag("by-id", "bid")) p.SortProperty = SortProperty.Hid; - if (ctx.MatchFlag("by-message-count", "bmc")) p.SortProperty = SortProperty.MessageCount; - if (ctx.MatchFlag("by-created", "bc")) p.SortProperty = SortProperty.CreationDate; - if (ctx.MatchFlag("by-last-fronted", "by-last-front", "by-last-switch", "blf", "bls")) p.SortProperty = SortProperty.LastSwitch; - if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage; - if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate; - if (ctx.MatchFlag("random")) p.SortProperty = SortProperty.Random; + // Then call the specific renderers + if (opts.Type == ListType.Short) + ShortRenderer(eb, page); + else + LongRenderer(eb, page); - // Sort reverse? - if (ctx.MatchFlag("r", "rev", "reverse")) - p.Reverse = true; - - // Privacy filter (default is public only) - if (ctx.MatchFlag("a", "all")) p.PrivacyFilter = null; - if (ctx.MatchFlag("private-only", "private", "priv")) p.PrivacyFilter = PrivacyLevel.Private; - if (ctx.MatchFlag("public-only", "public", "pub")) p.PrivacyFilter = PrivacyLevel.Public; - - // PERM CHECK: If we're trying to access non-public members of another system, error - if (p.PrivacyFilter != PrivacyLevel.Public && lookupCtx != LookupContext.ByOwner) - // TODO: should this just return null instead of throwing or something? >.> - throw new PKError("You cannot look up private members of another system."); - - // Additional fields to include in the search results - if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf")) - p.IncludeLastSwitch = true; - if (ctx.MatchFlag("with-last-message", "with-last-proxy", "wlm", "wlp")) - p.IncludeLastMessage = true; - if (ctx.MatchFlag("with-message-count", "wmc")) - p.IncludeMessageCount = true; - if (ctx.MatchFlag("with-created", "wc")) - p.IncludeCreated = true; - if (ctx.MatchFlag("with-avatar", "with-image", "wa", "wi", "ia", "ii", "img")) - p.IncludeAvatar = true; - - // Always show the sort property, too - if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true; - if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage= true; - if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true; - if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true; - - // Done! - return p; + return Task.CompletedTask; } - public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx, IDatabase db, SystemId system, string embedTitle, MemberListOptions opts) + void ShortRenderer(EmbedBuilder eb, IEnumerable page) { - // We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime - // We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout) - var members = (await db.Execute(conn => conn.QueryMemberList(system, opts.ToQueryOptions()))) - .SortByMemberListOptions(opts, lookupCtx) - .ToList(); - - var itemsPerPage = opts.Type == ListType.Short ? 25 : 5; - await ctx.Paginate(members.ToAsyncEnumerable(), members.Count, itemsPerPage, embedTitle, Renderer); - - // Base renderer, dispatches based on type - Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) + // We may end up over the description character limit + // so run it through a helper that "makes it work" :) + eb.WithSimpleLineContent(page.Select(m => { - // Add a global footer with the filter/sort string + result count - eb.WithFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}."); - - // Then call the specific renderers - if (opts.Type == ListType.Short) - ShortRenderer(eb, page); - else - LongRenderer(eb, page); - - return Task.CompletedTask; - } + var ret = $"[`{m.Hid}`] **{m.NameFor(ctx)}** "; - void ShortRenderer(DiscordEmbedBuilder eb, IEnumerable page) - { - // We may end up over the description character limit - // so run it through a helper that "makes it work" :) - eb.WithSimpleLineContent(page.Select(m => + switch (opts.SortProperty) { - if (m.HasProxyTags) - { - var proxyTagsString = m.ProxyTagsString(); - if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak? - proxyTagsString = "tags too long, see member card"; - return $"[`{m.Hid}`] **{m.NameFor(ctx)}** *(*{proxyTagsString}*)*"; - } + case SortProperty.Birthdate: + { + var birthday = m.BirthdayFor(lookupCtx); + if (birthday != null) + ret += $"(birthday: {m.BirthdayString})"; + break; + } + case SortProperty.DisplayName: + { + if (m.DisplayName != null && m.NamePrivacy.CanAccess(lookupCtx)) + ret += $"({m.DisplayName})"; + break; + } + case SortProperty.MessageCount: + { + if (m.MessageCountFor(lookupCtx) is { } count) + ret += $"({count} messages)"; + break; + } + case SortProperty.LastSwitch: + { + if (m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw)) + ret += $"(last switched in: )"; + break; + } + // case SortProperty.LastMessage: + // { + // if (m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg)) + // ret += $"(last message: )"; + // break; + // } + case SortProperty.CreationDate: + { + if (m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created)) + ret += $"(created at )"; + break; + } + default: + { + if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is { } count) + { + ret += $"({count} messages)"; + } + else if (opts.IncludeDisplayName && m.DisplayName != null && m.NamePrivacy.CanAccess(lookupCtx)) + { + ret += $"({m.DisplayName})"; + } + else if (opts.IncludeLastSwitch && + m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw)) + { + ret += $"(last switched in: )"; + } + // else if (opts.IncludeLastMessage && m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg)) + // ret += $"(last message: )"; + else if (opts.IncludeCreated && + m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created)) + { + ret += $"(created at )"; + } + else if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatarUrl) + { + ret += $"([avatar URL]({avatarUrl}))"; + } + else if (opts.IncludePronouns && m.PronounsFor(lookupCtx) is { } pronouns) + { + ret += $"({pronouns})"; + } + else if (m.HasProxyTags) + { + var proxyTagsString = m.ProxyTagsString(); + if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak? + proxyTagsString = "tags too long, see member card"; + ret += $"*(*{proxyTagsString}*)*"; + } - return $"[`{m.Hid}`] **{m.NameFor(ctx)}**"; - })); - } - - void LongRenderer(DiscordEmbedBuilder eb, IEnumerable page) - { - var zone = ctx.System?.Zone ?? DateTimeZone.Utc; - foreach (var m in page) - { - var profile = new StringBuilder($"**ID**: {m.Hid}"); - - if (m.DisplayName != null && m.NamePrivacy.CanAccess(lookupCtx)) - profile.Append($"\n**Display name**: {m.DisplayName}"); - - if (m.PronounsFor(lookupCtx) is {} pronouns) - profile.Append($"\n**Pronouns**: {pronouns}"); - - if (m.BirthdayFor(lookupCtx) != null) - profile.Append($"\n**Birthdate**: {m.BirthdayString}"); - - if (m.ProxyTags.Count > 0) - profile.Append($"\n**Proxy tags**: {m.ProxyTagsString()}"); - - if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is {} count && count > 0) - profile.Append($"\n**Message count:** {count}"); - - if (opts.IncludeLastMessage && m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg)) - profile.Append($"\n**Last message:** {DiscordUtils.SnowflakeToInstant(lastMsg.Value).FormatZoned(zone)}"); - - if (opts.IncludeLastSwitch && m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw)) - profile.Append($"\n**Last switched in:** {lastSw.Value.FormatZoned(zone)}"); - - if (opts.IncludeCreated && m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created)) - profile.Append($"\n**Created on:** {created.FormatZoned(zone)}"); - - if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is {} avatar) - profile.Append($"\n**Avatar URL:** {avatar}"); - - if (m.DescriptionFor(lookupCtx) is {} desc) - profile.Append($"\n\n{desc}"); - - if (m.MemberVisibility == PrivacyLevel.Private) - profile.Append("\n*(this member is hidden)*"); - - eb.AddField(m.NameFor(ctx), profile.ToString().Truncate(1024)); + break; + } } + + return ret; + })); + } + + void LongRenderer(EmbedBuilder eb, IEnumerable page) + { + foreach (var m in page) + { + var profile = new StringBuilder($"**ID**: {m.Hid}"); + + if (m.DisplayName != null && m.NamePrivacy.CanAccess(lookupCtx)) + profile.Append($"\n**Display name**: {m.DisplayName}"); + + if (m.PronounsFor(lookupCtx) is { } pronouns) + profile.Append($"\n**Pronouns**: {pronouns}"); + + if (m.BirthdayFor(lookupCtx) != null) + profile.Append($"\n**Birthdate**: {m.BirthdayString}"); + + if (m.ProxyTags.Count > 0) + profile.Append($"\n**Proxy tags**: {m.ProxyTagsString()}"); + + if ((opts.IncludeMessageCount || opts.SortProperty == SortProperty.MessageCount) && + m.MessageCountFor(lookupCtx) is { } count && count > 0) + profile.Append($"\n**Message count:** {count}"); + + // if ((opts.IncludeLastMessage || opts.SortProperty == SortProperty.LastMessage) && m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg)) + // profile.Append($"\n**Last message:** {DiscordUtils.SnowflakeToInstant(lastMsg.Value).FormatZoned(zone)}"); + + if ((opts.IncludeLastSwitch || opts.SortProperty == SortProperty.LastSwitch) && + m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw)) + profile.Append($"\n**Last switched in:** {lastSw.Value.FormatZoned(ctx.Zone)}"); + + if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) && + m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created)) + profile.Append($"\n**Created on:** {created.FormatZoned(ctx.Zone)}"); + + if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatar) + profile.Append($"\n**Avatar URL:** {avatar.TryGetCleanCdnUrl()}"); + + if (m.DescriptionFor(lookupCtx) is { } desc) + profile.Append($"\n\n{desc}"); + + if (m.MemberVisibility == PrivacyLevel.Private) + profile.Append("\n*(this member is hidden)*"); + + eb.Field(new Embed.Field(m.NameFor(ctx), profile.ToString().Truncate(1024))); } } } -} + + public static async Task RenderGroupList(this Context ctx, LookupContext lookupCtx, + SystemId system, string embedTitle, string color, ListOptions opts) + { + // We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime + // We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout) + var groups = (await ctx.Database.Execute(conn => conn.QueryGroupList(system, opts.ToQueryOptions()))) + .SortByGroupListOptions(opts, lookupCtx) + .ToList(); + + var itemsPerPage = opts.Type == ListType.Short ? 25 : 5; + await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, itemsPerPage, embedTitle, color, Renderer); + + // Base renderer, dispatches based on type + Task Renderer(EmbedBuilder eb, IEnumerable page) + { + // Add a global footer with the filter/sort string + result count + eb.Footer(new Embed.EmbedFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(groups.Count)}.")); + + // Then call the specific renderers + if (opts.Type == ListType.Short) + ShortRenderer(eb, page); + else + LongRenderer(eb, page); + + return Task.CompletedTask; + } + + void ShortRenderer(EmbedBuilder eb, IEnumerable page) + { + // We may end up over the description character limit + // so run it through a helper that "makes it work" :) + eb.WithSimpleLineContent(page.Select(g => + { + var ret = $"[`{g.Hid}`] **{g.NameFor(ctx)}** "; + + switch (opts.SortProperty) + { + case SortProperty.DisplayName: + { + if (g.NamePrivacy.CanAccess(lookupCtx) && g.DisplayName != null) + ret += $"({g.DisplayName})"; + break; + } + case SortProperty.CreationDate: + { + if (g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created)) + ret += $"(created at )"; + break; + } + default: + { + if (opts.IncludeCreated && + g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created)) + { + ret += $"(created at )"; + } + else if (opts.IncludeDisplayName && g.DisplayName != null && g.NamePrivacy.CanAccess(lookupCtx)) + { + ret += $"({g.DisplayName})"; + } + else if (opts.IncludeAvatar && g.IconFor(lookupCtx) is { } avatarUrl) + { + ret += $"([avatar URL]({avatarUrl}))"; + } + else + { + // -priv/-pub and listprivacy affects whether count is shown + // -all and visibility affects what the count is + if (ctx.DirectLookupContextFor(system) == LookupContext.ByOwner) + { + if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner) + { + if (ctx.MatchFlag("all", "a")) + { + ret += $"({"member".ToQuantity(g.TotalMemberCount)})"; + } + else + { + ret += $"({"member".ToQuantity(g.PublicMemberCount)})"; + } + } + } + else + { + if (g.ListPrivacy == PrivacyLevel.Public) + { + ret += $"({"member".ToQuantity(g.PublicMemberCount)})"; + } + } + } + + break; + } + } + + return ret; + })); + } + + void LongRenderer(EmbedBuilder eb, IEnumerable page) + { + foreach (var g in page) + { + var profile = new StringBuilder($"**ID**: {g.Hid}"); + + if (g.DisplayName != null && g.NamePrivacy.CanAccess(lookupCtx)) + profile.Append($"\n**Display name**: {g.DisplayName}"); + + if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner) + { + if (ctx.MatchFlag("all", "a") && ctx.DirectLookupContextFor(system) == LookupContext.ByOwner) + profile.Append($"\n**Member Count:** {g.TotalMemberCount}"); + else + profile.Append($"\n**Member Count:** {g.PublicMemberCount}"); + } + + if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) && + g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created)) + profile.Append($"\n**Created on:** {created.FormatZoned(ctx.Zone)}"); + + if (opts.IncludeAvatar && g.IconFor(lookupCtx) is { } avatar) + profile.Append($"\n**Avatar URL:** {avatar.TryGetCleanCdnUrl()}"); + + if (g.DescriptionFor(lookupCtx) is { } desc) + profile.Append($"\n\n{desc}"); + + if (g.Visibility == PrivacyLevel.Private) + profile.Append("\n*(this group is hidden)*"); + + eb.Field(new Embed.Field(g.NameFor(ctx), profile.ToString().Truncate(1024))); + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Lists/ListOptions.cs b/PluralKit.Bot/Commands/Lists/ListOptions.cs new file mode 100644 index 00000000..39e12367 --- /dev/null +++ b/PluralKit.Bot/Commands/Lists/ListOptions.cs @@ -0,0 +1,166 @@ +using System.Text; + +using Humanizer; + +using NodaTime; + +using PluralKit.Core; + +#nullable enable +namespace PluralKit.Bot; + +public class ListOptions +{ + public SortProperty SortProperty { get; set; } = SortProperty.Name; + public bool Reverse { get; set; } + + public PrivacyLevel? PrivacyFilter { get; set; } = PrivacyLevel.Public; + public GroupId? GroupFilter { get; set; } + public string? Search { get; set; } + public bool SearchDescription { get; set; } + + public ListType Type { get; set; } + public bool IncludeMessageCount { get; set; } + public bool IncludeLastSwitch { get; set; } + public bool IncludeLastMessage { get; set; } + public bool IncludeCreated { get; set; } + public bool IncludeAvatar { get; set; } + public bool IncludePronouns { get; set; } + public bool IncludeDisplayName { get; set; } + + public string CreateFilterString() + { + var str = new StringBuilder(); + str.Append("Sorting "); + if (SortProperty != SortProperty.Random) str.Append("by "); + str.Append(SortProperty switch + { + SortProperty.Name => "name", + SortProperty.Hid => "ID", + SortProperty.DisplayName => "display name", + SortProperty.CreationDate => "creation date", + SortProperty.LastMessage => "last message", + SortProperty.LastSwitch => "last switch", + SortProperty.MessageCount => "message count", + SortProperty.Birthdate => "birthday", + SortProperty.Random => "randomly", + _ => new ArgumentOutOfRangeException($"Couldn't find readable string for sort property {SortProperty}") + }); + + if (Search != null) + { + str.Append($", searching for \"{Search.Truncate(100)}\""); + if (SearchDescription) str.Append(" (including description)"); + } + + str.Append(PrivacyFilter switch + { + null => ", showing all items", + PrivacyLevel.Private => ", showing only private items", + PrivacyLevel.Public => "", // (default, no extra line needed) + _ => new ArgumentOutOfRangeException( + $"Couldn't find readable string for privacy filter {PrivacyFilter}") + }); + + return str.ToString(); + } + + public DatabaseViewsExt.ListQueryOptions ToQueryOptions() => + new() + { + PrivacyFilter = PrivacyFilter, + GroupFilter = GroupFilter, + Search = Search, + SearchDescription = SearchDescription + }; +} + +public static class ListOptionsExt +{ + public static IEnumerable SortByMemberListOptions(this IEnumerable input, + ListOptions opts, LookupContext ctx) + { + IComparer ReverseMaybe(IComparer c) => + opts.Reverse ? Comparer.Create((a, b) => c.Compare(b, a)) : c; + + var randGen = new global::System.Random(); + + var culture = StringComparer.InvariantCultureIgnoreCase; + return (opts.SortProperty switch + { + // As for the OrderByDescending HasValue calls: https://www.jerriepelser.com/blog/orderby-with-null-values/ + // We want nulls last no matter what, even if orders are reversed + SortProperty.Hid => input.OrderBy(m => m.Hid, ReverseMaybe(culture)), + SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)), + SortProperty.CreationDate => input + .OrderByDescending(m => m.MetadataPrivacy.CanAccess(ctx)) + .ThenBy(m => m.MetadataPrivacy.Get(ctx, m.Created, default), ReverseMaybe(Comparer.Default)), + SortProperty.MessageCount => input + .OrderByDescending(m => m.MessageCount != 0 && m.MetadataPrivacy.CanAccess(ctx)) + .ThenByDescending(m => m.MetadataPrivacy.Get(ctx, m.MessageCount, 0), ReverseMaybe(Comparer.Default)), + SortProperty.DisplayName => input + .OrderByDescending(m => m.DisplayName != null && m.NamePrivacy.CanAccess(ctx)) + .ThenBy(m => m.NamePrivacy.Get(ctx, m.DisplayName), ReverseMaybe(culture)), + SortProperty.Birthdate => input + .OrderByDescending(m => m.AnnualBirthday.HasValue && m.BirthdayPrivacy.CanAccess(ctx)) + .ThenBy(m => m.BirthdayPrivacy.Get(ctx, m.AnnualBirthday), ReverseMaybe(Comparer.Default)), + SortProperty.LastMessage => throw new PKError( + "Sorting by last message is temporarily disabled due to database issues, sorry."), + // SortProperty.LastMessage => input + // .OrderByDescending(m => m.LastMessage.HasValue) + // .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer.Default)), + SortProperty.LastSwitch => input + .OrderByDescending(m => m.LastSwitchTime.HasValue && m.MetadataPrivacy.CanAccess(ctx)) + .ThenByDescending(m => m.MetadataPrivacy.Get(ctx, m.LastSwitchTime), ReverseMaybe(Comparer.Default)), + SortProperty.Random => input + .OrderBy(m => randGen.Next()), + _ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}") + }) + // Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values) + .ThenBy(m => m.NameFor(ctx), culture); + } + + public static IEnumerable SortByGroupListOptions(this IEnumerable input, + ListOptions opts, LookupContext ctx) + { + IComparer ReverseMaybe(IComparer c) => + opts.Reverse ? Comparer.Create((a, b) => c.Compare(b, a)) : c; + + var randGen = new global::System.Random(); + + var culture = StringComparer.InvariantCultureIgnoreCase; + return (opts.SortProperty switch + { + // As for the OrderByDescending HasValue calls: https://www.jerriepelser.com/blog/orderby-with-null-values/ + // We want nulls last no matter what, even if orders are reversed + SortProperty.Hid => input.OrderBy(g => g.Hid, ReverseMaybe(culture)), + SortProperty.Name => input.OrderBy(g => g.NameFor(ctx), ReverseMaybe(culture)), + SortProperty.CreationDate => input + .OrderByDescending(g => g.MetadataPrivacy.CanAccess(ctx)) + .ThenBy(g => g.MetadataPrivacy.Get(ctx, g.Created, default), ReverseMaybe(Comparer.Default)), + SortProperty.DisplayName => input + .OrderByDescending(g => g.DisplayName != null && g.NamePrivacy.CanAccess(ctx)) + .ThenBy(g => g.NamePrivacy.Get(ctx, g.DisplayName), ReverseMaybe(culture)), + SortProperty.Random => input + .OrderBy(g => randGen.Next()), + _ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}") + }) + // Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values) + .ThenBy(g => g.NameFor(ctx), culture); + } +} + +public enum SortProperty +{ + Name, + DisplayName, + Hid, + MessageCount, + CreationDate, + LastSwitch, + LastMessage, + Birthdate, + Random +} + +public enum ListType { Short, Long } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs b/PluralKit.Bot/Commands/Lists/MemberListOptions.cs deleted file mode 100644 index 254188b4..00000000 --- a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -using NodaTime; - -using PluralKit.Core; - -#nullable enable -namespace PluralKit.Bot -{ - public class MemberListOptions - { - public SortProperty SortProperty { get; set; } = SortProperty.Name; - public bool Reverse { get; set; } - - public PrivacyLevel? PrivacyFilter { get; set; } = PrivacyLevel.Public; - public GroupId? GroupFilter { get; set; } - public string? Search { get; set; } - public bool SearchDescription { get; set; } - - public ListType Type { get; set; } - public bool IncludeMessageCount { get; set; } - public bool IncludeLastSwitch { get; set; } - public bool IncludeLastMessage { get; set; } - public bool IncludeCreated { get; set; } - public bool IncludeAvatar { get; set; } - - public string CreateFilterString() - { - var str = new StringBuilder(); - str.Append("Sorting "); - if (SortProperty != SortProperty.Random) str.Append("by "); - str.Append(SortProperty switch - { - SortProperty.Name => "member name", - SortProperty.Hid => "member ID", - SortProperty.DisplayName => "display name", - SortProperty.CreationDate => "creation date", - SortProperty.LastMessage => "last message", - SortProperty.LastSwitch => "last switch", - SortProperty.MessageCount => "message count", - SortProperty.Birthdate => "birthday", - SortProperty.Random => "randomly", - _ => new ArgumentOutOfRangeException($"Couldn't find readable string for sort property {SortProperty}") - }); - - if (Search != null) - { - str.Append($", searching for \"{Search}\""); - if (SearchDescription) str.Append(" (including description)"); - } - - str.Append(PrivacyFilter switch - { - null => ", showing all members", - PrivacyLevel.Private => ", showing only private members", - PrivacyLevel.Public => "", // (default, no extra line needed) - _ => new ArgumentOutOfRangeException($"Couldn't find readable string for privacy filter {PrivacyFilter}") - }); - - return str.ToString(); - } - - public DatabaseViewsExt.MemberListQueryOptions ToQueryOptions() => - new DatabaseViewsExt.MemberListQueryOptions - { - PrivacyFilter = PrivacyFilter, - GroupFilter = GroupFilter, - Search = Search, - SearchDescription = SearchDescription - }; - } - - public static class MemberListOptionsExt - { - public static IEnumerable SortByMemberListOptions(this IEnumerable input, MemberListOptions opts, LookupContext ctx) - { - IComparer ReverseMaybe(IComparer c) => - opts.Reverse ? Comparer.Create((a, b) => c.Compare(b, a)) : c; - - var randGen = new global::System.Random(); - - var culture = StringComparer.InvariantCultureIgnoreCase; - return (opts.SortProperty switch - { - // As for the OrderByDescending HasValue calls: https://www.jerriepelser.com/blog/orderby-with-null-values/ - // We want nulls last no matter what, even if orders are reversed - SortProperty.Hid => input.OrderBy(m => m.Hid, ReverseMaybe(culture)), - SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)), - SortProperty.CreationDate => input.OrderBy(m => m.Created, ReverseMaybe(Comparer.Default)), - SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount, ReverseMaybe(Comparer.Default)), - SortProperty.DisplayName => input - .OrderByDescending(m => m.DisplayName != null) - .ThenBy(m => m.DisplayName, ReverseMaybe(culture)), - SortProperty.Birthdate => input - .OrderByDescending(m => m.AnnualBirthday.HasValue) - .ThenBy(m => m.AnnualBirthday, ReverseMaybe(Comparer.Default)), - SortProperty.LastMessage => input - .OrderByDescending(m => m.LastMessage.HasValue) - .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer.Default)), - SortProperty.LastSwitch => input - .OrderByDescending(m => m.LastSwitchTime.HasValue) - .ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer.Default)), - SortProperty.Random => input - .OrderBy(m => randGen.Next()), - _ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}") - }) - // Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values) - .ThenBy(m => m.NameFor(ctx), culture); - } - } - - public enum SortProperty - { - Name, - DisplayName, - Hid, - MessageCount, - CreationDate, - LastSwitch, - LastMessage, - Birthdate, - Random - } - - public enum ListType - { - Short, - Long - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 68c2830e..c06fc24a 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -1,100 +1,143 @@ -using System.Threading.Tasks; using System.Net; -using System.Net.Http; using System.Web; using Dapper; -using DSharpPlus.Entities; +using Myriad.Builders; using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Member { - public class Member + private readonly HttpClient _client; + private readonly DispatchService _dispatch; + private readonly EmbedService _embeds; + + public Member(EmbedService embeds, HttpClient client, + DispatchService dispatch) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; - private readonly HttpClient _client; - - public Member(EmbedService embeds, IDatabase db, ModelRepository repo, HttpClient client) + _embeds = embeds; + _client = client; + _dispatch = dispatch; + } + + public async Task NewMember(Context ctx) + { + if (ctx.System == null) throw Errors.NoSystemError; + var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name."); + + // Hard name length cap + if (memberName.Length > Limits.MaxMemberNameLength) + throw Errors.StringTooLongError("Member name", memberName.Length, Limits.MaxMemberNameLength); + + // Warn if there's already a member by this name + var existingMember = await ctx.Repository.GetMemberByName(ctx.System.Id, memberName); + if (existingMember != null) { - _embeds = embeds; - _db = db; - _repo = repo; - _client = client; + var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?"; + if (!await ctx.PromptYesNo(msg, "Create")) throw new PKError("Member creation cancelled."); } - public async Task NewMember(Context ctx) { - if (ctx.System == null) throw Errors.NoSystemError; - var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name."); - - // Hard name length cap - if (memberName.Length > Limits.MaxMemberNameLength) throw Errors.MemberNameTooLongError(memberName.Length); + await using var conn = await ctx.Database.Obtain(); - // Warn if there's already a member by this name - var existingMember = await _db.Execute(c => _repo.GetMemberByName(c, ctx.System.Id, memberName)); - if (existingMember != null) { - var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?"; - if (!await ctx.PromptYesNo(msg)) throw new PKError("Member creation cancelled."); + // Enforce per-system member limit + var memberCount = await ctx.Repository.GetSystemMemberCount(ctx.System.Id); + var memberLimit = ctx.Config.MemberLimitOverride ?? Limits.MaxMemberCount; + if (memberCount >= memberLimit) + throw Errors.MemberLimitReachedError(memberLimit); + + // Create the member + var member = await ctx.Repository.CreateMember(ctx.System.Id, memberName, conn); + memberCount++; + + JObject dispatchData = new JObject(); + dispatchData.Add("name", memberName); + + if (ctx.Config.MemberDefaultPrivate) + { + var patch = new MemberPatch().WithAllPrivacy(PrivacyLevel.Private); + await ctx.Repository.UpdateMember(member.Id, patch, conn); + dispatchData.Merge(patch.ToJson()); + } + + // Try to match an image attached to the message + var avatarArg = ctx.Message.Attachments.FirstOrDefault(); + Exception imageMatchError = null; + if (avatarArg != null) + try + { + await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Url); + await ctx.Repository.UpdateMember(member.Id, new MemberPatch { AvatarUrl = avatarArg.Url }, conn); + + dispatchData.Add("avatar_url", avatarArg.Url); + } + catch (Exception e) + { + imageMatchError = e; } - await using var conn = await _db.Obtain(); - - // Enforce per-system member limit - var memberCount = await _repo.GetSystemMemberCount(conn, ctx.System.Id); - var memberLimit = ctx.System.MemberLimitOverride ?? Limits.MaxMemberCount; - if (memberCount >= memberLimit) - throw Errors.MemberLimitReachedError(memberLimit); - - // Create the member - var member = await _repo.CreateMember(conn, ctx.System.Id, memberName); - memberCount++; - - // Send confirmation and space hint - await ctx.Reply($"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Check out the getting started page for how to get a member up and running: https://pluralkit.me/start#create-a-member"); - if (await _db.Execute(conn => conn.QuerySingleAsync("select has_private_members(@System)", - new {System = ctx.System.Id}))) //if has private members - await ctx.Reply($"{Emojis.Warn} This member is currently **public**. To change this, use `pk;member {member.Hid} private`."); - - if (memberName.Contains(" ")) - await ctx.Reply($"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it, or just use the member's 5-character ID (which is `{member.Hid}`)."); - if (memberCount >= memberLimit) - await ctx.Reply($"{Emojis.Warn} You have reached the per-system member limit ({memberLimit}). You will be unable to create additional members until existing members are deleted."); - else if (memberCount >= Limits.MaxMembersWarnThreshold(memberLimit)) - await ctx.Reply($"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Please review your member list for unused or duplicate members."); - } - - public async Task ViewMember(Context ctx, PKMember target) + _ = _dispatch.Dispatch(member.Id, new UpdateDispatchData { - var system = await _db.Execute(c => _repo.GetSystem(c, target.System)); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system))); - } + Event = DispatchEvent.CREATE_MEMBER, + EventData = dispatchData, + }); - public async Task Soulscream(Context ctx, PKMember target) - { - // this is for a meme, please don't take this code seriously. :) - - var name = target.NameFor(ctx.LookupContextFor(target)); - var encoded = HttpUtility.UrlEncode(name); - - var resp = await _client.GetAsync($"https://onomancer.sibr.dev/api/generateStats2?name={encoded}"); - if (resp.StatusCode != HttpStatusCode.OK) - // lol - return; - - var data = JObject.Parse(await resp.Content.ReadAsStringAsync()); - var scream = data["soulscream"]!.Value(); - - var eb = new DiscordEmbedBuilder() - .WithColor(DiscordColor.Red) - .WithTitle(name) - .WithUrl($"https://onomancer.sibr.dev/reflect?name={encoded}") - .WithDescription($"*{scream}*"); - await ctx.Reply(embed: eb.Build()); - } + // Send confirmation and space hint + await ctx.Reply( + $"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Check out the getting started page for how to get a member up and running: https://pluralkit.me/start#create-a-member"); + // todo: move this to ModelRepository + if (await ctx.Database.Execute(conn => conn.QuerySingleAsync("select has_private_members(@System)", + new { System = ctx.System.Id })) && !ctx.Config.MemberDefaultPrivate) //if has private members + await ctx.Reply( + $"{Emojis.Warn} This member is currently **public**. To change this, use `pk;member {member.Hid} private`."); + if (avatarArg != null) + if (imageMatchError == null) + await ctx.Reply( + $"{Emojis.Success} Member avatar set to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working."); + else + await ctx.Reply($"{Emojis.Error} Couldn't set avatar: {imageMatchError.Message}"); + if (memberName.Contains(" ")) + await ctx.Reply( + $"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it, or just use the member's 5-character ID (which is `{member.Hid}`)."); + if (memberCount >= memberLimit) + await ctx.Reply( + $"{Emojis.Warn} You have reached the per-system member limit ({memberLimit}). You will be unable to create additional members until existing members are deleted."); + else if (memberCount >= Limits.WarnThreshold(memberLimit)) + await ctx.Reply( + $"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Please review your member list for unused or duplicate members."); } -} + + public async Task ViewMember(Context ctx, PKMember target) + { + var system = await ctx.Repository.GetSystem(target.System); + await ctx.Reply( + embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system.Id), ctx.Zone)); + } + + public async Task Soulscream(Context ctx, PKMember target) + { + // this is for a meme, please don't take this code seriously. :) + + var name = target.NameFor(ctx.LookupContextFor(target.System)); + var encoded = HttpUtility.UrlEncode(name); + + var resp = await _client.GetAsync($"https://onomancer.sibr.dev/api/generateStats2?name={encoded}"); + if (resp.StatusCode != HttpStatusCode.OK) + // lol + return; + + var data = JObject.Parse(await resp.Content.ReadAsStringAsync()); + var scream = data["soulscream"]!.Value(); + + var eb = new EmbedBuilder() + .Color(DiscordUtils.Red) + .Title(name) + .Url($"https://onomancer.sibr.dev/reflect?name={encoded}") + .Description($"*{scream}*"); + await ctx.Reply(embed: eb.Build()); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 786779b6..612ef816 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -1,163 +1,169 @@ #nullable enable -using System; -using System.Threading.Tasks; - -using DSharpPlus.Entities; +using Myriad.Builders; +using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class MemberAvatar { - public class MemberAvatar + private readonly HttpClient _client; + + public MemberAvatar(HttpClient client) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _client = client; + } - public MemberAvatar(IDatabase db, ModelRepository repo) + private async Task AvatarClear(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs) + { + await UpdateAvatar(location, ctx, target, null); + if (location == AvatarLocation.Server) { - _db = db; - _repo = repo; - } - - private async Task AvatarClear(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs) - { - await UpdateAvatar(location, ctx, target, null); - if (location == AvatarLocation.Server) - { - if (target.AvatarUrl != null) - await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member will now use the global avatar in this server (**{ctx.Guild.Name}**)."); - else - await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member now has no avatar."); - } + if (target.AvatarUrl != null) + await ctx.Reply( + $"{Emojis.Success} Member server avatar cleared. This member will now use the global avatar in this server (**{ctx.Guild.Name}**)."); else - { - if (mgs?.AvatarUrl != null) - await ctx.Reply($"{Emojis.Success} Member avatar cleared. Note that this member has a server-specific avatar set here, type `pk;member {target.Reference()} serveravatar clear` if you wish to clear that too."); - else - await ctx.Reply($"{Emojis.Success} Member avatar cleared."); - } + await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member now has no avatar."); } - - private async Task AvatarShow(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? guildData) + else { - var currentValue = location == AvatarLocation.Member ? target.AvatarUrl : guildData?.AvatarUrl; - var canAccess = location != AvatarLocation.Member || target.AvatarPrivacy.CanAccess(ctx.LookupContextFor(target)); - if (string.IsNullOrEmpty(currentValue) || !canAccess) - { - if (location == AvatarLocation.Member) - { - if (target.System == ctx.System?.Id) - throw new PKSyntaxError("This member does not have an avatar set. Set one by attaching an image to this command, or by passing an image URL or @mention."); - throw new PKError("This member does not have an avatar set."); - } - - if (location == AvatarLocation.Server) - throw new PKError($"This member does not have a server avatar set. Type `pk;member {target.Reference()} avatar` to see their global avatar."); - } - - var field = location == AvatarLocation.Server ? $"server avatar (for {ctx.Guild.Name})" : "avatar"; - var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar"; - - var eb = new DiscordEmbedBuilder() - .WithTitle($"{target.NameFor(ctx)}'s {field}") - .WithImageUrl(currentValue); - if (target.System == ctx.System?.Id) - eb.WithDescription($"To clear, use `pk;member {target.Reference()} {cmd} clear`."); - await ctx.Reply(embed: eb.Build()); - } - - public async Task ServerAvatar(Context ctx, PKMember target) - { - ctx.CheckGuildContext(); - var guildData = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); - await AvatarCommandTree(AvatarLocation.Server, ctx, target, guildData); - } - - public async Task Avatar(Context ctx, PKMember target) - { - var guildData = ctx.Guild != null ? - await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)) - : null; - - await AvatarCommandTree(AvatarLocation.Member, ctx, target, guildData); - } - - private async Task AvatarCommandTree(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? guildData) - { - // First, see if we need to *clear* - if (await ctx.MatchClear(location == AvatarLocation.Server ? "this member's server avatar" : "this member's avatar")) - { - ctx.CheckSystem().CheckOwnMember(target); - await AvatarClear(location, ctx, target, guildData); - return; - } - - // Then, parse an image from the command (from various sources...) - var avatarArg = await ctx.MatchImage(); - if (avatarArg == null) - { - // If we didn't get any, just show the current avatar - await AvatarShow(location, ctx, target, guildData); - return; - } - - ctx.CheckSystem().CheckOwnMember(target); - await AvatarUtils.VerifyAvatarOrThrow(avatarArg.Value.Url); - await UpdateAvatar(location, ctx, target, avatarArg.Value.Url); - await PrintResponse(location, ctx, target, avatarArg.Value, guildData); - } - - private Task PrintResponse(AvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, - MemberGuildSettings? targetGuildData) - { - var typeFrag = location switch - { - AvatarLocation.Server => "server avatar", - AvatarLocation.Member => "avatar", - _ => throw new ArgumentOutOfRangeException(nameof(location)) - }; - - var serverFrag = location switch - { - AvatarLocation.Server => $" This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**).", - AvatarLocation.Member when targetGuildData?.AvatarUrl != null => $"\n{Emojis.Note} Note that this member *also* has a server-specific avatar set in this server (**{ctx.Guild.Name}**), and thus changing the global avatar will have no effect here.", - _ => "" - }; - - var msg = avatar.Source switch - { - AvatarSource.User => $"{Emojis.Success} Member {typeFrag} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.", - AvatarSource.Url => $"{Emojis.Success} Member {typeFrag} changed to the image at the given URL.{serverFrag}", - AvatarSource.Attachment => $"{Emojis.Success} Member {typeFrag} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.", - _ => throw new ArgumentOutOfRangeException() - }; - - // The attachment's already right there, no need to preview it. - var hasEmbed = avatar.Source != AvatarSource.Attachment; - return hasEmbed - ? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(avatar.Url).Build()) - : ctx.Reply(msg); - } - - private Task UpdateAvatar(AvatarLocation location, Context ctx, PKMember target, string? url) - { - switch (location) - { - case AvatarLocation.Server: - var serverPatch = new MemberGuildPatch { AvatarUrl = url }; - return _db.Execute(c => _repo.UpsertMemberGuild(c, target.Id, ctx.Guild.Id, serverPatch)); - case AvatarLocation.Member: - var memberPatch = new MemberPatch { AvatarUrl = url }; - return _db.Execute(c => _repo.UpdateMember(c, target.Id, memberPatch)); - default: - throw new ArgumentOutOfRangeException($"Unknown avatar location {location}"); - } - } - - private enum AvatarLocation - { - Member, - Server + if (mgs?.AvatarUrl != null) + await ctx.Reply( + $"{Emojis.Success} Member avatar cleared. Note that this member has a server-specific avatar set here, type `pk;member {target.Reference(ctx)} serveravatar clear` if you wish to clear that too."); + else + await ctx.Reply($"{Emojis.Success} Member avatar cleared."); } } + + private async Task AvatarShow(AvatarLocation location, Context ctx, PKMember target, + MemberGuildSettings? guildData) + { + // todo: this privacy code is really confusing + // for now, we skip privacy flag/config parsing for this, but it would be good to fix that at some point + + var currentValue = location == AvatarLocation.Member ? target.AvatarUrl : guildData?.AvatarUrl; + var canAccess = location != AvatarLocation.Member || + target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.System)); + if (string.IsNullOrEmpty(currentValue) || !canAccess) + { + if (location == AvatarLocation.Member) + { + if (target.System == ctx.System?.Id) + throw new PKSyntaxError( + "This member does not have an avatar set. Set one by attaching an image to this command, or by passing an image URL or @mention."); + throw new PKError("This member does not have an avatar set."); + } + + if (location == AvatarLocation.Server) + throw new PKError( + $"This member does not have a server avatar set. Type `pk;member {target.Reference(ctx)} avatar` to see their global avatar."); + } + + var field = location == AvatarLocation.Server ? $"server avatar (for {ctx.Guild.Name})" : "avatar"; + var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar"; + + var eb = new EmbedBuilder() + .Title($"{target.NameFor(ctx)}'s {field}") + .Image(new Embed.EmbedImage(currentValue?.TryGetCleanCdnUrl())); + if (target.System == ctx.System?.Id) + eb.Description($"To clear, use `pk;member {target.Reference(ctx)} {cmd} clear`."); + await ctx.Reply(embed: eb.Build()); + } + + public async Task ServerAvatar(Context ctx, PKMember target) + { + ctx.CheckGuildContext(); + var guildData = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + await AvatarCommandTree(AvatarLocation.Server, ctx, target, guildData); + } + + public async Task Avatar(Context ctx, PKMember target) + { + var guildData = ctx.Guild != null + ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) + : null; + + await AvatarCommandTree(AvatarLocation.Member, ctx, target, guildData); + } + + private async Task AvatarCommandTree(AvatarLocation location, Context ctx, PKMember target, + MemberGuildSettings? guildData) + { + // First, see if we need to *clear* + if (await ctx.MatchClear(location == AvatarLocation.Server + ? "this member's server avatar" + : "this member's avatar")) + { + ctx.CheckSystem().CheckOwnMember(target); + await AvatarClear(location, ctx, target, guildData); + return; + } + + // Then, parse an image from the command (from various sources...) + var avatarArg = await ctx.MatchImage(); + if (avatarArg == null) + { + // If we didn't get any, just show the current avatar + await AvatarShow(location, ctx, target, guildData); + return; + } + + ctx.CheckSystem().CheckOwnMember(target); + await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Value.Url); + await UpdateAvatar(location, ctx, target, avatarArg.Value.Url); + await PrintResponse(location, ctx, target, avatarArg.Value, guildData); + } + + private Task PrintResponse(AvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, + MemberGuildSettings? targetGuildData) + { + var typeFrag = location switch + { + AvatarLocation.Server => "server avatar", + AvatarLocation.Member => "avatar", + _ => throw new ArgumentOutOfRangeException(nameof(location)) + }; + + var serverFrag = location switch + { + AvatarLocation.Server => + $" This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**).", + AvatarLocation.Member when targetGuildData?.AvatarUrl != null => + $"\n{Emojis.Note} Note that this member *also* has a server-specific avatar set in this server (**{ctx.Guild.Name}**), and thus changing the global avatar will have no effect here.", + _ => "" + }; + + var msg = avatar.Source switch + { + AvatarSource.User => + $"{Emojis.Success} Member {typeFrag} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.", + AvatarSource.Url => + $"{Emojis.Success} Member {typeFrag} changed to the image at the given URL.{serverFrag}", + AvatarSource.Attachment => + $"{Emojis.Success} Member {typeFrag} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.", + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = avatar.Source != AvatarSource.Attachment; + return hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(avatar.Url)).Build()) + : ctx.Reply(msg); + } + + private Task UpdateAvatar(AvatarLocation location, Context ctx, PKMember target, string? url) + { + switch (location) + { + case AvatarLocation.Server: + return ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, new MemberGuildPatch { AvatarUrl = url }); + case AvatarLocation.Member: + return ctx.Repository.UpdateMember(target.Id, new MemberPatch { AvatarUrl = url }); + default: + throw new ArgumentOutOfRangeException($"Unknown avatar location {location}"); + } + } + + private enum AvatarLocation { Member, Server } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 43d8fa82..2d027145 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -1,499 +1,682 @@ using System.Text.RegularExpressions; -using System.Threading.Tasks; -using System; - -using Dapper; - -using DSharpPlus.Entities; +using Myriad.Builders; +using Myriad.Types; using NodaTime; +using NodaTime.Extensions; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class MemberEdit { - public class MemberEdit + private readonly HttpClient _client; + + public MemberEdit(HttpClient client) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _client = client; + } - public MemberEdit(IDatabase db, ModelRepository repo) + public async Task Name(Context ctx, PKMember target) + { + ctx.CheckSystem().CheckOwnMember(target); + + var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new name for the member."); + + // Hard name length cap + if (newName.Length > Limits.MaxMemberNameLength) + throw Errors.StringTooLongError("Member name", newName.Length, Limits.MaxMemberNameLength); + + // Warn if there's already a member by this name + var existingMember = await ctx.Repository.GetMemberByName(ctx.System.Id, newName); + if (existingMember != null && existingMember.Id != target.Id) { - _db = db; - _repo = repo; + var msg = + $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?"; + if (!await ctx.PromptYesNo(msg, "Rename")) throw new PKError("Member renaming cancelled."); } - public async Task Name(Context ctx, PKMember target) + // Rename the member + var patch = new MemberPatch { Name = Partial.Present(newName) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member renamed."); + if (newName.Contains(" ")) + await ctx.Reply( + $"{Emojis.Note} Note that this member's name now contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); + if (target.DisplayName != null) + await ctx.Reply( + $"{Emojis.Note} Note that this member has a display name set ({target.DisplayName}), and will be proxied using that name instead."); + + if (ctx.Guild != null) { - ctx.CheckSystem().CheckOwnMember(target); - - var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new name for the member."); - - // Hard name length cap - if (newName.Length > Limits.MaxMemberNameLength) throw Errors.MemberNameTooLongError(newName.Length); - - // Warn if there's already a member by this name - var existingMember = await _db.Execute(conn => _repo.GetMemberByName(conn, ctx.System.Id, newName)); - if (existingMember != null && existingMember.Id != target.Id) - { - var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?"; - if (!await ctx.PromptYesNo(msg)) throw new PKError("Member renaming cancelled."); - } - - // Rename the member - var patch = new MemberPatch {Name = Partial.Present(newName)}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Member renamed."); - if (newName.Contains(" ")) await ctx.Reply($"{Emojis.Note} Note that this member's name now contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); - if (target.DisplayName != null) await ctx.Reply($"{Emojis.Note} Note that this member has a display name set ({target.DisplayName}), and will be proxied using that name instead."); - - if (ctx.Guild != null) - { - var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); - if (memberGuildConfig.DisplayName != null) - await ctx.Reply($"{Emojis.Note} Note that this member has a server name set ({memberGuildConfig.DisplayName}) in this server ({ctx.Guild.Name}), and will be proxied using that name here."); - } - } - - public async Task Description(Context ctx, PKMember target) { - if (await ctx.MatchClear("this member's description")) - { - ctx.CheckOwnMember(target); - - var patch = new MemberPatch {Description = Partial.Null()}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - await ctx.Reply($"{Emojis.Success} Member description cleared."); - } - else if (!ctx.HasNext()) - { - if (!target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) - throw Errors.LookupNotAllowed; - if (target.Description == null) - if (ctx.System?.Id == target.System) - await ctx.Reply($"This member does not have a description set. To set one, type `pk;member {target.Reference()} description `."); - else - await ctx.Reply("This member does not have a description set."); - else if (ctx.MatchFlag("r", "raw")) - await ctx.Reply($"```\n{target.Description}\n```"); - else - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("Member description") - .WithDescription(target.Description) - .AddField("\u200B", $"To print the description with formatting, type `pk;member {target.Reference()} description -raw`." - + (ctx.System?.Id == target.System ? $" To clear it, type `pk;member {target.Reference()} description -clear`." : "")) - .Build()); - } - else - { - ctx.CheckOwnMember(target); - - var description = ctx.RemainderOrNull().NormalizeLineEndSpacing(); - if (description.IsLongerThan(Limits.MaxDescriptionLength)) - throw Errors.DescriptionTooLongError(description.Length); - - var patch = new MemberPatch {Description = Partial.Present(description)}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Member description changed."); - } - } - - public async Task Pronouns(Context ctx, PKMember target) { - if (await ctx.MatchClear("this member's pronouns")) - { - ctx.CheckOwnMember(target); - - var patch = new MemberPatch {Pronouns = Partial.Null()}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - await ctx.Reply($"{Emojis.Success} Member pronouns cleared."); - } - else if (!ctx.HasNext()) - { - if (!target.PronounPrivacy.CanAccess(ctx.LookupContextFor(target.System))) - throw Errors.LookupNotAllowed; - if (target.Pronouns == null) - if (ctx.System?.Id == target.System) - await ctx.Reply($"This member does not have pronouns set. To set some, type `pk;member {target.Reference()} pronouns `."); - else - await ctx.Reply("This member does not have pronouns set."); - else - await ctx.Reply($"**{target.NameFor(ctx)}**'s pronouns are **{target.Pronouns}**." - + (ctx.System?.Id == target.System ? $" To clear them, type `pk;member {target.Reference()} pronouns -clear`." : "")); - } - else - { - ctx.CheckOwnMember(target); - - var pronouns = ctx.RemainderOrNull().NormalizeLineEndSpacing(); - if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) - throw Errors.MemberPronounsTooLongError(pronouns.Length); - - var patch = new MemberPatch {Pronouns = Partial.Present(pronouns)}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Member pronouns changed."); - } - } - - public async Task Color(Context ctx, PKMember target) - { - var color = ctx.RemainderOrNull(); - if (await ctx.MatchClear()) - { - ctx.CheckOwnMember(target); - - var patch = new MemberPatch {Color = Partial.Null()}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Member color cleared."); - } - else if (!ctx.HasNext()) - { - // if (!target.ColorPrivacy.CanAccess(ctx.LookupContextFor(target.System))) - // throw Errors.LookupNotAllowed; - - if (target.Color == null) - if (ctx.System?.Id == target.System) - await ctx.Reply( - $"This member does not have a color set. To set one, type `pk;member {target.Reference()} color `."); - else - await ctx.Reply("This member does not have a color set."); - else - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("Member color") - .WithColor(target.Color.ToDiscordColor().Value) - .WithThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20") - .WithDescription($"This member's color is **#{target.Color}**." - + (ctx.System?.Id == target.System ? $" To clear it, type `pk;member {target.Reference()} color -clear`." : "")) - .Build()); - } - else - { - ctx.CheckOwnMember(target); - - if (color.StartsWith("#")) color = color.Substring(1); - if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); - - var patch = new MemberPatch {Color = Partial.Present(color.ToLowerInvariant())}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle($"{Emojis.Success} Member color changed.") - .WithColor(color.ToDiscordColor().Value) - .WithThumbnail($"https://fakeimg.pl/256x256/{color}/?text=%20") - .Build()); - } - } - public async Task Birthday(Context ctx, PKMember target) - { - if (await ctx.MatchClear("this member's birthday")) - { - ctx.CheckOwnMember(target); - - var patch = new MemberPatch {Birthday = Partial.Null()}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Member birthdate cleared."); - } - else if (!ctx.HasNext()) - { - if (!target.BirthdayPrivacy.CanAccess(ctx.LookupContextFor(target.System))) - throw Errors.LookupNotAllowed; - - if (target.Birthday == null) - await ctx.Reply("This member does not have a birthdate set." - + (ctx.System?.Id == target.System ? $" To set one, type `pk;member {target.Reference()} birthdate `." : "")); - else - await ctx.Reply($"This member's birthdate is **{target.BirthdayString}**." - + (ctx.System?.Id == target.System ? $" To clear it, type `pk;member {target.Reference()} birthdate -clear`." : "")); - } - else - { - ctx.CheckOwnMember(target); - - var birthdayStr = ctx.RemainderOrNull(); - var birthday = DateUtils.ParseDate(birthdayStr, true); - if (birthday == null) throw Errors.BirthdayParseError(birthdayStr); - - var patch = new MemberPatch {Birthday = Partial.Present(birthday)}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Member birthdate changed."); - } - } - - private async Task CreateMemberNameInfoEmbed(Context ctx, PKMember target) - { - var lcx = ctx.LookupContextFor(target); - - MemberGuildSettings memberGuildConfig = null; - if (ctx.Guild != null) - memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); - - var eb = new DiscordEmbedBuilder().WithTitle($"Member names") - .WithFooter($"Member ID: {target.Hid} | Active name in bold. Server name overrides display name, which overrides base name."); - - if (target.DisplayName == null && memberGuildConfig?.DisplayName == null) - eb.AddField("Name", $"**{target.NameFor(ctx)}**"); - else - eb.AddField("Name", target.NameFor(ctx)); - - if (target.NamePrivacy.CanAccess(lcx)) - { - if (target.DisplayName != null && memberGuildConfig?.DisplayName == null) - eb.AddField("Display Name", $"**{target.DisplayName}**"); - else - eb.AddField("Display Name", target.DisplayName ?? "*(none)*"); - } - - if (ctx.Guild != null) - { - if (memberGuildConfig?.DisplayName != null) - eb.AddField($"Server Name (in {ctx.Guild.Name})", $"**{memberGuildConfig.DisplayName}**"); - else - eb.AddField($"Server Name (in {ctx.Guild.Name})", memberGuildConfig?.DisplayName ?? "*(none)*"); - } - - return eb; - } - - public async Task DisplayName(Context ctx, PKMember target) - { - async Task PrintSuccess(string text) - { - var successStr = text; - if (ctx.Guild != null) - { - var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); - if (memberGuildConfig.DisplayName != null) - successStr += $" However, this member has a server name set in this server ({ctx.Guild.Name}), and will be proxied using that name, \"{memberGuildConfig.DisplayName}\", here."; - } - - await ctx.Reply(successStr); - } - - if (await ctx.MatchClear("this member's display name")) - { - ctx.CheckOwnMember(target); - - var patch = new MemberPatch {DisplayName = Partial.Null()}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await PrintSuccess($"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\"."); - } - else if (!ctx.HasNext()) - { - // No perms check, display name isn't covered by member privacy - var eb = await CreateMemberNameInfoEmbed(ctx, target); - if (ctx.System?.Id == target.System) - eb.WithDescription($"To change display name, type `pk;member {target.Reference()} displayname `.\nTo clear it, type `pk;member {target.Reference()} displayname -clear`."); - await ctx.Reply(embed: eb.Build()); - } - else - { - ctx.CheckOwnMember(target); - - var newDisplayName = ctx.RemainderOrNull(); - - var patch = new MemberPatch {DisplayName = Partial.Present(newDisplayName)}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await PrintSuccess($"{Emojis.Success} Member display name changed. This member will now be proxied using the name \"{newDisplayName}\"."); - } - } - - public async Task ServerName(Context ctx, PKMember target) - { - ctx.CheckGuildContext(); - - if (await ctx.MatchClear("this member's server name")) - { - ctx.CheckOwnMember(target); - - var patch = new MemberGuildPatch {DisplayName = null}; - await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.Guild.Id, patch)); - - if (target.DisplayName != null) - await ctx.Reply($"{Emojis.Success} Member server name cleared. This member will now be proxied using their global display name \"{target.DisplayName}\" in this server ({ctx.Guild.Name})."); - else - await ctx.Reply($"{Emojis.Success} Member server name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\" in this server ({ctx.Guild.Name})."); - } - else if (!ctx.HasNext()) - { - // No perms check, server name isn't covered by member privacy - var eb = await CreateMemberNameInfoEmbed(ctx, target); - if (ctx.System?.Id == target.System) - eb.WithDescription($"To change server name, type `pk;member {target.Reference()} servername `.\nTo clear it, type `pk;member {target.Reference()} servername -clear`."); - await ctx.Reply(embed: eb.Build()); - } - else - { - ctx.CheckOwnMember(target); - - var newServerName = ctx.RemainderOrNull(); - - var patch = new MemberGuildPatch {DisplayName = newServerName}; - await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.Guild.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Member server name changed. This member will now be proxied using the name \"{newServerName}\" in this server ({ctx.Guild.Name})."); - } - } - - public async Task KeepProxy(Context ctx, PKMember target) - { - ctx.CheckSystem().CheckOwnMember(target); - - bool newValue; - if (ctx.Match("on", "enabled", "true", "yes")) newValue = true; - else if (ctx.Match("off", "disabled", "false", "no")) newValue = false; - else if (ctx.HasNext()) throw new PKSyntaxError("You must pass either \"on\" or \"off\"."); - else - { - if (target.KeepProxy) - await ctx.Reply("This member has keepproxy **enabled**, which means proxy tags will be **included** in the resulting message when proxying."); - else - await ctx.Reply("This member has keepproxy **disabled**, which means proxy tags will **not** be included in the resulting message when proxying."); - return; - }; - - var patch = new MemberPatch {KeepProxy = Partial.Present(newValue)}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - if (newValue) - await ctx.Reply($"{Emojis.Success} Member proxy tags will now be included in the resulting message when proxying."); - else - await ctx.Reply($"{Emojis.Success} Member proxy tags will now not be included in the resulting message when proxying."); - } - - public async Task MemberAutoproxy(Context ctx, PKMember target) - { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; - - bool newValue; - if (ctx.Match("on", "enabled", "true", "yes") || ctx.MatchFlag("on", "enabled", "true", "yes")) newValue = true; - else if (ctx.Match("off", "disabled", "false", "no") || ctx.MatchFlag("off", "disabled", "false", "no")) newValue = false; - else if (ctx.HasNext()) throw new PKSyntaxError("You must pass either \"on\" or \"off\"."); - else - { - if (target.AllowAutoproxy) - await ctx.Reply("Latch/front autoproxy are **enabled** for this member. This member will be automatically proxied when autoproxy is set to latch or front mode."); - else - await ctx.Reply("Latch/front autoproxy are **disabled** for this member. This member will not be automatically proxied when autoproxy is set to latch or front mode."); - return; - }; - - var patch = new MemberPatch {AllowAutoproxy = Partial.Present(newValue)}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - if (newValue) - await ctx.Reply($"{Emojis.Success} Latch / front autoproxy have been **enabled** for this member."); - else - await ctx.Reply($"{Emojis.Success} Latch / front autoproxy have been **disabled** for this member."); - } - - public async Task Privacy(Context ctx, PKMember target, PrivacyLevel? newValueFromCommand) - { - ctx.CheckSystem().CheckOwnMember(target); - - // Display privacy settings - if (!ctx.HasNext() && newValueFromCommand == null) - { - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle($"Current privacy settings for {target.NameFor(ctx)}") - .AddField("Name (replaces name with display name if member has one)",target.NamePrivacy.Explanation()) - .AddField("Description", target.DescriptionPrivacy.Explanation()) - .AddField("Avatar", target.AvatarPrivacy.Explanation()) - .AddField("Birthday", target.BirthdayPrivacy.Explanation()) - .AddField("Pronouns", target.PronounPrivacy.Explanation()) - .AddField("Meta (message count, last front, last message)",target.MetadataPrivacy.Explanation()) - .AddField("Visibility", target.MemberVisibility.Explanation()) - .WithDescription("To edit privacy settings, use the command:\n`pk;member privacy `\n\n- `subject` is one of `name`, `description`, `avatar`, `birthday`, `pronouns`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`.") - .Build()); - return; - } - - // Get guild settings (mostly for warnings and such) - MemberGuildSettings guildSettings = null; - if (ctx.Guild != null) - guildSettings = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); - - async Task SetAll(PrivacyLevel level) - { - await _db.Execute(c => _repo.UpdateMember(c, target.Id, new MemberPatch().WithAllPrivacy(level))); - - if (level == PrivacyLevel.Private) - await ctx.Reply($"{Emojis.Success} All {target.NameFor(ctx)}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the member card."); - else - await ctx.Reply($"{Emojis.Success} All {target.NameFor(ctx)}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the member card."); - } - - async Task SetLevel(MemberPrivacySubject subject, PrivacyLevel level) - { - await _db.Execute(c => _repo.UpdateMember(c, target.Id, new MemberPatch().WithPrivacy(subject, level))); - - var subjectName = subject switch - { - MemberPrivacySubject.Name => "name privacy", - MemberPrivacySubject.Description => "description privacy", - MemberPrivacySubject.Avatar => "avatar privacy", - MemberPrivacySubject.Pronouns => "pronoun privacy", - MemberPrivacySubject.Birthday => "birthday privacy", - MemberPrivacySubject.Metadata => "metadata privacy", - MemberPrivacySubject.Visibility => "visibility", - _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") - }; - - var explanation = (subject, level) switch - { - (MemberPrivacySubject.Name, PrivacyLevel.Private) => "This member's name is now hidden from other systems, and will be replaced by the member's display name.", - (MemberPrivacySubject.Description, PrivacyLevel.Private) => "This member's description is now hidden from other systems.", - (MemberPrivacySubject.Avatar, PrivacyLevel.Private) => "This member's avatar is now hidden from other systems.", - (MemberPrivacySubject.Birthday, PrivacyLevel.Private) => "This member's birthday is now hidden from other systems.", - (MemberPrivacySubject.Pronouns, PrivacyLevel.Private) => "This member's pronouns are now hidden from other systems.", - (MemberPrivacySubject.Metadata, PrivacyLevel.Private) => "This member's metadata (eg. created timestamp, message count, etc) is now hidden from other systems.", - (MemberPrivacySubject.Visibility, PrivacyLevel.Private) => "This member is now hidden from member lists.", - - (MemberPrivacySubject.Name, PrivacyLevel.Public) => "This member's name is no longer hidden from other systems.", - (MemberPrivacySubject.Description, PrivacyLevel.Public) => "This member's description is no longer hidden from other systems.", - (MemberPrivacySubject.Avatar, PrivacyLevel.Public) => "This member's avatar is no longer hidden from other systems.", - (MemberPrivacySubject.Birthday, PrivacyLevel.Public) => "This member's birthday is no longer hidden from other systems.", - (MemberPrivacySubject.Pronouns, PrivacyLevel.Public) => "This member's pronouns are no longer hidden other systems.", - (MemberPrivacySubject.Metadata, PrivacyLevel.Public) => "This member's metadata (eg. created timestamp, message count, etc) is no longer hidden from other systems.", - (MemberPrivacySubject.Visibility, PrivacyLevel.Public) => "This member is no longer hidden from member lists.", - - _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") - }; - - await ctx.Reply($"{Emojis.Success} {target.NameFor(ctx)}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"); - - // Name privacy only works given a display name - if (subject == MemberPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null) - await ctx.Reply($"{Emojis.Warn} This member does not have a display name set, and name privacy **will not take effect**."); - - // Avatar privacy doesn't apply when proxying if no server avatar is set - if (subject == MemberPrivacySubject.Avatar && level == PrivacyLevel.Private && guildSettings?.AvatarUrl == null) - await ctx.Reply($"{Emojis.Warn} This member does not have a server avatar set, so *proxying* will **still show the member avatar**. If you want to hide your avatar when proxying here, set a server avatar: `pk;member {target.Reference()} serveravatar`"); - } - - if (ctx.Match("all") || newValueFromCommand != null) - await SetAll(newValueFromCommand ?? ctx.PopPrivacyLevel()); - else - await SetLevel(ctx.PopMemberPrivacySubject(), ctx.PopPrivacyLevel()); - } - - public async Task Delete(Context ctx, PKMember target) - { - ctx.CheckSystem().CheckOwnMember(target); - - await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete \"{target.NameFor(ctx)}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__"); - if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled; - - await _db.Execute(conn => _repo.DeleteMember(conn, target.Id)); - - await ctx.Reply($"{Emojis.Success} Member deleted."); + var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + if (memberGuildConfig.DisplayName != null) + await ctx.Reply( + $"{Emojis.Note} Note that this member has a server name set ({memberGuildConfig.DisplayName}) in this server ({ctx.Guild.Name}), and will be proxied using that name here."); } } -} + + public async Task Description(Context ctx, PKMember target) + { + ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy); + + var noDescriptionSetMessage = "This member does not have a description set."; + if (ctx.System?.Id == target.System) + noDescriptionSetMessage += + $" To set one, type `pk;member {target.Reference(ctx)} description `."; + + if (ctx.MatchRaw()) + { + if (target.Description == null) + await ctx.Reply(noDescriptionSetMessage); + else + await ctx.Reply($"```\n{target.Description}\n```"); + return; + } + + if (!ctx.HasNext(false)) + { + if (target.Description == null) + await ctx.Reply(noDescriptionSetMessage); + else + await ctx.Reply(embed: new EmbedBuilder() + .Title("Member description") + .Description(target.Description) + .Field(new Embed.Field("\u200B", + $"To print the description with formatting, type `pk;member {target.Reference(ctx)} description -raw`." + + (ctx.System?.Id == target.System + ? $" To clear it, type `pk;member {target.Reference(ctx)} description -clear`." + : ""))) + .Build()); + return; + } + + ctx.CheckOwnMember(target); + + if (await ctx.MatchClear("this member's description")) + { + var patch = new MemberPatch { Description = Partial.Null() }; + await ctx.Repository.UpdateMember(target.Id, patch); + await ctx.Reply($"{Emojis.Success} Member description cleared."); + } + else + { + var description = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + if (description.IsLongerThan(Limits.MaxDescriptionLength)) + throw Errors.StringTooLongError("Description", description.Length, Limits.MaxDescriptionLength); + + var patch = new MemberPatch { Description = Partial.Present(description) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member description changed."); + } + } + + public async Task Pronouns(Context ctx, PKMember target) + { + var noPronounsSetMessage = "This member does not have pronouns set."; + if (ctx.System?.Id == target.System) + noPronounsSetMessage += $"To set some, type `pk;member {target.Reference(ctx)} pronouns `."; + + ctx.CheckSystemPrivacy(target.System, target.PronounPrivacy); + + if (ctx.MatchRaw()) + { + if (target.Pronouns == null) + await ctx.Reply(noPronounsSetMessage); + else + await ctx.Reply($"```\n{target.Pronouns}\n```"); + return; + } + + if (!ctx.HasNext(false)) + { + if (target.Pronouns == null) + await ctx.Reply(noPronounsSetMessage); + else + await ctx.Reply( + $"**{target.NameFor(ctx)}**'s pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `pk;member {target.Reference(ctx)} pronouns -raw`." + + (ctx.System?.Id == target.System + ? $" To clear them, type `pk;member {target.Reference(ctx)} pronouns -clear`." + : "")); + return; + } + + ctx.CheckOwnMember(target); + + if (await ctx.MatchClear("this member's pronouns")) + { + var patch = new MemberPatch { Pronouns = Partial.Null() }; + await ctx.Repository.UpdateMember(target.Id, patch); + await ctx.Reply($"{Emojis.Success} Member pronouns cleared."); + } + else + { + var pronouns = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) + throw Errors.StringTooLongError("Pronouns", pronouns.Length, Limits.MaxPronounsLength); + + var patch = new MemberPatch { Pronouns = Partial.Present(pronouns) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member pronouns changed."); + } + } + + public async Task BannerImage(Context ctx, PKMember target) + { + ctx.CheckOwnMember(target); + + async Task ClearBannerImage() + { + await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = null }); + await ctx.Reply($"{Emojis.Success} Member banner image cleared."); + } + + async Task SetBannerImage(ParsedImage img) + { + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true); + + await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.Url }); + + var msg = img.Source switch + { + AvatarSource.Url => $"{Emojis.Success} Member banner image changed to the image at the given URL.", + AvatarSource.Attachment => + $"{Emojis.Success} Member banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", + AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + async Task ShowBannerImage() + { + if ((target.BannerImage?.Trim() ?? "").Length > 0) + { + var eb = new EmbedBuilder() + .Title($"{target.NameFor(ctx)}'s banner image") + .Image(new Embed.EmbedImage(target.BannerImage)) + .Description($"To clear, use `pk;member {target.Hid} banner clear`."); + await ctx.Reply(embed: eb.Build()); + } + else + { + throw new PKSyntaxError( + "This member does not have a banner image set. Set one by attaching an image to this command, or by passing an image URL or @mention."); + } + } + + if (await ctx.MatchClear("this member's banner image")) + await ClearBannerImage(); + else if (await ctx.MatchImage() is { } img) + await SetBannerImage(img); + else + await ShowBannerImage(); + } + + public async Task Color(Context ctx, PKMember target) + { + var color = ctx.RemainderOrNull(); + if (await ctx.MatchClear()) + { + ctx.CheckOwnMember(target); + + var patch = new MemberPatch { Color = Partial.Null() }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member color cleared."); + } + else if (!ctx.HasNext()) + { + // if (!target.ColorPrivacy.CanAccess(ctx.LookupContextFor(target.System))) + // throw Errors.LookupNotAllowed; + + if (target.Color == null) + if (ctx.System?.Id == target.System) + await ctx.Reply( + $"This member does not have a color set. To set one, type `pk;member {target.Reference(ctx)} color `."); + else + await ctx.Reply("This member does not have a color set."); + else + await ctx.Reply(embed: new EmbedBuilder() + .Title("Member color") + .Color(target.Color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20")) + .Description($"This member's color is **#{target.Color}**." + + (ctx.System?.Id == target.System + ? $" To clear it, type `pk;member {target.Reference(ctx)} color -clear`." + : "")) + .Build()); + } + else + { + ctx.CheckOwnMember(target); + + if (color.StartsWith("#")) color = color.Substring(1); + if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); + + var patch = new MemberPatch { Color = Partial.Present(color.ToLowerInvariant()) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply(embed: new EmbedBuilder() + .Title($"{Emojis.Success} Member color changed.") + .Color(color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{color}/?text=%20")) + .Build()); + } + } + + public async Task Birthday(Context ctx, PKMember target) + { + if (await ctx.MatchClear("this member's birthday")) + { + ctx.CheckOwnMember(target); + + var patch = new MemberPatch { Birthday = Partial.Null() }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member birthdate cleared."); + } + else if (!ctx.HasNext()) + { + ctx.CheckSystemPrivacy(target.System, target.BirthdayPrivacy); + + if (target.Birthday == null) + await ctx.Reply("This member does not have a birthdate set." + + (ctx.System?.Id == target.System + ? $" To set one, type `pk;member {target.Reference(ctx)} birthdate `." + : "")); + else + await ctx.Reply($"This member's birthdate is **{target.BirthdayString}**." + + (ctx.System?.Id == target.System + ? $" To clear it, type `pk;member {target.Reference(ctx)} birthdate -clear`." + : "")); + } + else + { + ctx.CheckOwnMember(target); + + var birthdayStr = ctx.RemainderOrNull(); + + LocalDate? birthday; + if (birthdayStr == "today" || birthdayStr == "now") + birthday = SystemClock.Instance.InZone(ctx.Zone).GetCurrentDate(); + else + birthday = DateUtils.ParseDate(birthdayStr, true); + + if (birthday == null) throw Errors.BirthdayParseError(birthdayStr); + + var patch = new MemberPatch { Birthday = Partial.Present(birthday) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member birthdate changed."); + } + } + + private string boldIf(string str, bool condition) => condition ? $"**{str}**" : str; + + private async Task CreateMemberNameInfoEmbed(Context ctx, PKMember target) + { + var lcx = ctx.LookupContextFor(target.System); + + MemberGuildSettings memberGuildConfig = null; + if (ctx.Guild != null) + memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + + var eb = new EmbedBuilder() + .Title("Member names") + .Footer(new Embed.EmbedFooter( + $"Member ID: {target.Hid} | Active name in bold. Server name overrides display name, which overrides base name.")); + + var showDisplayName = target.NamePrivacy.CanAccess(lcx); + + eb.Field(new Embed.Field("Name", boldIf( + target.NameFor(ctx), + (!showDisplayName || target.DisplayName == null) && memberGuildConfig?.DisplayName == null + ))); + + eb.Field(new Embed.Field("Display name", (target.DisplayName != null && showDisplayName) + ? boldIf(target.DisplayName, memberGuildConfig?.DisplayName == null) + : "*(none)*" + )); + + if (ctx.Guild != null) + eb.Field(new Embed.Field($"Server Name (in {ctx.Guild.Name})", + memberGuildConfig?.DisplayName != null + ? $"**{memberGuildConfig.DisplayName}**" + : "*(none)*" + )); + + return eb; + } + + public async Task DisplayName(Context ctx, PKMember target) + { + async Task PrintSuccess(string text) + { + var successStr = text; + if (ctx.Guild != null) + { + var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + if (memberGuildConfig.DisplayName != null) + successStr += + $" However, this member has a server name set in this server ({ctx.Guild.Name}), and will be proxied using that name, \"{memberGuildConfig.DisplayName}\", here."; + } + + await ctx.Reply(successStr); + } + + var noDisplayNameSetMessage = "This member does not have a display name set."; + if (ctx.System?.Id == target.System) + noDisplayNameSetMessage += + $" To set one, type `pk;member {target.Reference(ctx)} displayname `."; + + // No perms check, display name isn't covered by member privacy + + if (ctx.MatchRaw()) + { + if (target.DisplayName == null) + await ctx.Reply(noDisplayNameSetMessage); + else + await ctx.Reply($"```\n{target.DisplayName}\n```"); + return; + } + + if (!ctx.HasNext(false)) + { + var eb = await CreateMemberNameInfoEmbed(ctx, target); + var reference = target.Reference(ctx); + if (ctx.System?.Id == target.System) + eb.Description( + $"To change display name, type `pk;member {reference} displayname `." + + $"To clear it, type `pk;member {reference} displayname -clear`." + + $"To print the raw display name, type `pk;member {reference} displayname -raw`."); + await ctx.Reply(embed: eb.Build()); + return; + } + + ctx.CheckOwnMember(target); + + if (await ctx.MatchClear("this member's display name")) + { + var patch = new MemberPatch { DisplayName = Partial.Null() }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await PrintSuccess( + $"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.Name}\"."); + + if (target.NamePrivacy == PrivacyLevel.Private) + await ctx.Reply($"{Emojis.Warn} Since this member no longer has a display name set, their name privacy **can no longer take effect**."); + } + else + { + var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + + var patch = new MemberPatch { DisplayName = Partial.Present(newDisplayName) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await PrintSuccess( + $"{Emojis.Success} Member display name changed. This member will now be proxied using the name \"{newDisplayName}\"."); + } + } + + public async Task ServerName(Context ctx, PKMember target) + { + ctx.CheckGuildContext(); + + var noServerNameSetMessage = "This member does not have a server name set."; + if (ctx.System?.Id == target.System) + noServerNameSetMessage += + $" To set one, type `pk;member {target.Reference(ctx)} servername `."; + + // No perms check, display name isn't covered by member privacy + var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + + if (ctx.MatchRaw()) + { + if (memberGuildConfig.DisplayName == null) + await ctx.Reply(noServerNameSetMessage); + else + await ctx.Reply($"```\n{memberGuildConfig.DisplayName}\n```"); + return; + } + + if (!ctx.HasNext(false)) + { + var eb = await CreateMemberNameInfoEmbed(ctx, target); + var reference = target.Reference(ctx); + if (ctx.System?.Id == target.System) + eb.Description( + $"To change server name, type `pk;member {reference} servername `.\nTo clear it, type `pk;member {reference} servername -clear`.\nTo print the raw server name, type `pk;member {reference} servername -raw`."); + await ctx.Reply(embed: eb.Build()); + return; + } + + ctx.CheckOwnMember(target); + + if (await ctx.MatchClear("this member's server name")) + { + await ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, new MemberGuildPatch { DisplayName = null }); + + if (target.DisplayName != null) + await ctx.Reply( + $"{Emojis.Success} Member server name cleared. This member will now be proxied using their global display name \"{target.DisplayName}\" in this server ({ctx.Guild.Name})."); + else + await ctx.Reply( + $"{Emojis.Success} Member server name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\" in this server ({ctx.Guild.Name})."); + } + else + { + var newServerName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + + await ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, + new MemberGuildPatch { DisplayName = newServerName }); + + await ctx.Reply( + $"{Emojis.Success} Member server name changed. This member will now be proxied using the name \"{newServerName}\" in this server ({ctx.Guild.Name})."); + } + } + + public async Task KeepProxy(Context ctx, PKMember target) + { + ctx.CheckSystem().CheckOwnMember(target); + + bool newValue; + if (ctx.Match("on", "enabled", "true", "yes")) + { + newValue = true; + } + else if (ctx.Match("off", "disabled", "false", "no")) + { + newValue = false; + } + else if (ctx.HasNext()) + { + throw new PKSyntaxError("You must pass either \"on\" or \"off\"."); + } + else + { + if (target.KeepProxy) + await ctx.Reply( + "This member has keepproxy **enabled**, which means proxy tags will be **included** in the resulting message when proxying."); + else + await ctx.Reply( + "This member has keepproxy **disabled**, which means proxy tags will **not** be included in the resulting message when proxying."); + return; + } + + ; + + var patch = new MemberPatch { KeepProxy = Partial.Present(newValue) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + if (newValue) + await ctx.Reply( + $"{Emojis.Success} Member proxy tags will now be included in the resulting message when proxying."); + else + await ctx.Reply( + $"{Emojis.Success} Member proxy tags will now not be included in the resulting message when proxying."); + } + + public async Task MemberAutoproxy(Context ctx, PKMember target) + { + if (ctx.System == null) throw Errors.NoSystemError; + if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + + if (!ctx.HasNext()) + { + if (target.AllowAutoproxy) + await ctx.Reply( + "Latch/front autoproxy are **enabled** for this member. This member will be automatically proxied when autoproxy is set to latch or front mode."); + else + await ctx.Reply( + "Latch/front autoproxy are **disabled** for this member. This member will not be automatically proxied when autoproxy is set to latch or front mode."); + return; + } + + var newValue = ctx.MatchToggle(); + + var patch = new MemberPatch { AllowAutoproxy = Partial.Present(newValue) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + if (newValue) + await ctx.Reply($"{Emojis.Success} Latch / front autoproxy have been **enabled** for this member."); + else + await ctx.Reply($"{Emojis.Success} Latch / front autoproxy have been **disabled** for this member."); + } + + public async Task Privacy(Context ctx, PKMember target, PrivacyLevel? newValueFromCommand) + { + ctx.CheckSystem().CheckOwnMember(target); + + // Display privacy settings + if (!ctx.HasNext() && newValueFromCommand == null) + { + await ctx.Reply(embed: new EmbedBuilder() + .Title($"Current privacy settings for {target.NameFor(ctx)}") + .Field(new Embed.Field("Name (replaces name with display name if member has one)", + target.NamePrivacy.Explanation())) + .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) + .Field(new Embed.Field("Avatar", target.AvatarPrivacy.Explanation())) + .Field(new Embed.Field("Birthday", target.BirthdayPrivacy.Explanation())) + .Field(new Embed.Field("Pronouns", target.PronounPrivacy.Explanation())) + .Field(new Embed.Field("Meta (message count, last front, last message)", + target.MetadataPrivacy.Explanation())) + .Field(new Embed.Field("Visibility", target.MemberVisibility.Explanation())) + .Description( + "To edit privacy settings, use the command:\n`pk;member privacy `\n\n- `subject` is one of `name`, `description`, `avatar`, `birthday`, `pronouns`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + .Build()); + return; + } + + // Get guild settings (mostly for warnings and such) + MemberGuildSettings guildSettings = null; + if (ctx.Guild != null) + guildSettings = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + + async Task SetAll(PrivacyLevel level) + { + await ctx.Repository.UpdateMember(target.Id, new MemberPatch().WithAllPrivacy(level)); + + if (level == PrivacyLevel.Private) + await ctx.Reply( + $"{Emojis.Success} All {target.NameFor(ctx)}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the member card."); + else + await ctx.Reply( + $"{Emojis.Success} All {target.NameFor(ctx)}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the member card."); + } + + async Task SetLevel(MemberPrivacySubject subject, PrivacyLevel level) + { + await ctx.Repository.UpdateMember(target.Id, new MemberPatch().WithPrivacy(subject, level)); + + var subjectName = subject switch + { + MemberPrivacySubject.Name => "name privacy", + MemberPrivacySubject.Description => "description privacy", + MemberPrivacySubject.Avatar => "avatar privacy", + MemberPrivacySubject.Pronouns => "pronoun privacy", + MemberPrivacySubject.Birthday => "birthday privacy", + MemberPrivacySubject.Metadata => "metadata privacy", + MemberPrivacySubject.Visibility => "visibility", + _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") + }; + + var explanation = (subject, level) switch + { + (MemberPrivacySubject.Name, PrivacyLevel.Private) => + "This member's name is now hidden from other systems, and will be replaced by the member's display name.", + (MemberPrivacySubject.Description, PrivacyLevel.Private) => + "This member's description is now hidden from other systems.", + (MemberPrivacySubject.Avatar, PrivacyLevel.Private) => + "This member's avatar is now hidden from other systems.", + (MemberPrivacySubject.Birthday, PrivacyLevel.Private) => + "This member's birthday is now hidden from other systems.", + (MemberPrivacySubject.Pronouns, PrivacyLevel.Private) => + "This member's pronouns are now hidden from other systems.", + (MemberPrivacySubject.Metadata, PrivacyLevel.Private) => + "This member's metadata (eg. created timestamp, message count, etc) is now hidden from other systems.", + (MemberPrivacySubject.Visibility, PrivacyLevel.Private) => + "This member is now hidden from member lists.", + + (MemberPrivacySubject.Name, PrivacyLevel.Public) => + "This member's name is no longer hidden from other systems.", + (MemberPrivacySubject.Description, PrivacyLevel.Public) => + "This member's description is no longer hidden from other systems.", + (MemberPrivacySubject.Avatar, PrivacyLevel.Public) => + "This member's avatar is no longer hidden from other systems.", + (MemberPrivacySubject.Birthday, PrivacyLevel.Public) => + "This member's birthday is no longer hidden from other systems.", + (MemberPrivacySubject.Pronouns, PrivacyLevel.Public) => + "This member's pronouns are no longer hidden other systems.", + (MemberPrivacySubject.Metadata, PrivacyLevel.Public) => + "This member's metadata (eg. created timestamp, message count, etc) is no longer hidden from other systems.", + (MemberPrivacySubject.Visibility, PrivacyLevel.Public) => + "This member is no longer hidden from member lists.", + + _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") + }; + + await ctx.Reply( + $"{Emojis.Success} {target.NameFor(ctx)}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"); + + // Name privacy only works given a display name + if (subject == MemberPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null) + await ctx.Reply( + $"{Emojis.Warn} This member does not have a display name set, and name privacy **will not take effect**."); + + // Avatar privacy doesn't apply when proxying if no server avatar is set + if (subject == MemberPrivacySubject.Avatar && level == PrivacyLevel.Private && + guildSettings?.AvatarUrl == null) + await ctx.Reply( + $"{Emojis.Warn} This member does not have a server avatar set, so *proxying* will **still show the member avatar**. If you want to hide your avatar when proxying here, set a server avatar: `pk;member {target.Reference(ctx)} serveravatar`"); + } + + if (ctx.Match("all") || newValueFromCommand != null) + await SetAll(newValueFromCommand ?? ctx.PopPrivacyLevel()); + else + await SetLevel(ctx.PopMemberPrivacySubject(), ctx.PopPrivacyLevel()); + } + + public async Task Delete(Context ctx, PKMember target) + { + ctx.CheckSystem().CheckOwnMember(target); + + await ctx.Reply( + $"{Emojis.Warn} Are you sure you want to delete \"{target.NameFor(ctx)}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__"); + if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled; + + await ctx.Repository.DeleteMember(target.Id); + + await ctx.Reply($"{Emojis.Success} Member deleted."); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberGroup.cs b/PluralKit.Bot/Commands/MemberGroup.cs deleted file mode 100644 index 6a8f9f7b..00000000 --- a/PluralKit.Bot/Commands/MemberGroup.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using DSharpPlus.Entities; - -using PluralKit.Core; - -namespace PluralKit.Bot -{ - public class MemberGroup - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - - public MemberGroup(IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - } - - public async Task AddRemove(Context ctx, PKMember target, Groups.AddRemoveOperation op) - { - ctx.CheckSystem().CheckOwnMember(target); - - var groups = (await ctx.ParseGroupList(ctx.System.Id)) - .Select(g => g.Id) - .Distinct() - .ToList(); - - await using var conn = await _db.Obtain(); - var existingGroups = (await _repo.GetMemberGroups(conn, target.Id).ToListAsync()) - .Select(g => g.Id) - .Distinct() - .ToList(); - - List toAction; - - if (op == Groups.AddRemoveOperation.Add) - { - toAction = groups - .Where(group => !existingGroups.Contains(group)) - .ToList(); - - await _repo.AddGroupsToMember(conn, target.Id, toAction); - } - else if (op == Groups.AddRemoveOperation.Remove) - { - toAction = groups - .Where(group => existingGroups.Contains(group)) - .ToList(); - - await _repo.RemoveGroupsFromMember(conn, target.Id, toAction); - } - else return; // otherwise toAction "may be unassigned" - - await ctx.Reply(MiscUtils.GroupAddRemoveResponse(groups, toAction, op)); - } - - public async Task List(Context ctx, PKMember target) - { - await using var conn = await _db.Obtain(); - - var pctx = ctx.LookupContextFor(target.System); - - var groups = await _repo.GetMemberGroups(conn, target.Id) - .Where(g => g.Visibility.CanAccess(pctx)) - .OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase) - .ToListAsync(); - - var description = ""; - var msg = ""; - - if (groups.Count == 0) - description = "This member has no groups."; - else - description = string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**")); - - if (pctx == LookupContext.ByOwner) - { - msg += $"\n\nTo add this member to one or more groups, use `pk;m {target.Reference()} group add [group 2] [group 3...]`"; - if (groups.Count > 0) - msg += $"\nTo remove this member from one or more groups, use `pk;m {target.Reference()} group remove [group 2] [group 3...]`"; - } - - await ctx.Reply(msg, embed: (new DiscordEmbedBuilder().WithTitle($"{target.Name}'s groups").WithDescription(description)).Build()); - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs index d09ea4cf..0619adca 100644 --- a/PluralKit.Bot/Commands/MemberProxy.cs +++ b/PluralKit.Bot/Commands/MemberProxy.cs @@ -1,134 +1,127 @@ -using System.Linq; -using System.Threading.Tasks; - using Dapper; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class MemberProxy { - public class MemberProxy + public async Task Proxy(Context ctx, PKMember target) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - - public MemberProxy(IDatabase db, ModelRepository repo) + ctx.CheckSystem().CheckOwnMember(target); + + ProxyTag ParseProxyTags(string exampleProxy) { - _db = db; - _repo = repo; + // // Make sure there's one and only one instance of "text" in the example proxy given + var prefixAndSuffix = exampleProxy.Split("text"); + if (prefixAndSuffix.Length == 1) prefixAndSuffix = prefixAndSuffix[0].Split("TEXT"); + if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText; + if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText; + return new ProxyTag(prefixAndSuffix[0], prefixAndSuffix[1]); } - public async Task Proxy(Context ctx, PKMember target) + async Task WarnOnConflict(ProxyTag newTag) { - ctx.CheckSystem().CheckOwnMember(target); + var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix is not distinct from @Prefix and suffix is not distinct from @Suffix and id != @Existing"; + var conflicts = (await ctx.Database.Execute(conn => conn.QueryAsync(query, + new { newTag.Prefix, newTag.Suffix, Existing = target.Id, system = target.System }))).ToList(); - ProxyTag ParseProxyTags(string exampleProxy) - { - // // Make sure there's one and only one instance of "text" in the example proxy given - var prefixAndSuffix = exampleProxy.Split("text"); - if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText; - if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText; - return new ProxyTag(prefixAndSuffix[0], prefixAndSuffix[1]); - } - - async Task WarnOnConflict(ProxyTag newTag) - { - var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix = @Prefix and suffix = @Suffix and id != @Existing"; - var conflicts = (await _db.Execute(conn => conn.QueryAsync(query, - new {Prefix = newTag.Prefix, Suffix = newTag.Suffix, Existing = target.Id, system = target.System}))).ToList(); - - if (conflicts.Count <= 0) return true; + if (conflicts.Count <= 0) return true; - var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**"); - var msg = $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?"; - return await ctx.PromptYesNo(msg); - } - - // "Sub"command: clear flag - if (await ctx.MatchClear()) + var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**"); + var msg = $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?"; + return await ctx.PromptYesNo(msg, "Proceed"); + } + + // "Sub"command: clear flag + if (await ctx.MatchClear()) + { + // If we already have multiple tags, this would clear everything, so prompt that + if (target.ProxyTags.Count > 1) { - // If we already have multiple tags, this would clear everything, so prompt that - if (target.ProxyTags.Count > 1) - { - var msg = $"{Emojis.Warn} You already have multiple proxy tags set: {target.ProxyTagsString()}\nDo you want to clear them all?"; - if (!await ctx.PromptYesNo(msg)) - throw Errors.GenericCancelled(); - } - - var patch = new MemberPatch {ProxyTags = Partial.Present(new ProxyTag[0])}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Proxy tags cleared."); - } - // "Sub"command: no arguments; will print proxy tags - else if (!ctx.HasNext(skipFlags: false)) - { - if (target.ProxyTags.Count == 0) - await ctx.Reply("This member does not have any proxy tags."); - else - await ctx.Reply($"This member's proxy tags are:\n{target.ProxyTagsString("\n")}"); - } - // Subcommand: "add" - else if (ctx.Match("add", "append")) - { - if (!ctx.HasNext(skipFlags: false)) throw new PKSyntaxError("You must pass an example proxy to add (eg. `[text]` or `J:text`)."); - - var tagToAdd = ParseProxyTags(ctx.RemainderOrNull(skipFlags: false)); - if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target); - if (target.ProxyTags.Contains(tagToAdd)) - throw Errors.ProxyTagAlreadyExists(tagToAdd, target); - - if (!await WarnOnConflict(tagToAdd)) + var msg = $"{Emojis.Warn} You already have multiple proxy tags set: {target.ProxyTagsString()}\nDo you want to clear them all?"; + if (!await ctx.PromptYesNo(msg, "Clear")) throw Errors.GenericCancelled(); - - var newTags = target.ProxyTags.ToList(); - newTags.Add(tagToAdd); - var patch = new MemberPatch {ProxyTags = Partial.Present(newTags.ToArray())}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Added proxy tags {tagToAdd.ProxyString.AsCode()}."); } - // Subcommand: "remove" - else if (ctx.Match("remove", "delete")) - { - if (!ctx.HasNext(skipFlags: false)) throw new PKSyntaxError("You must pass a proxy tag to remove (eg. `[text]` or `J:text`)."); - var tagToRemove = ParseProxyTags(ctx.RemainderOrNull(skipFlags: false)); - if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target); - if (!target.ProxyTags.Contains(tagToRemove)) - throw Errors.ProxyTagDoesNotExist(tagToRemove, target); + var patch = new MemberPatch { ProxyTags = Partial.Present(new ProxyTag[0]) }; + await ctx.Repository.UpdateMember(target.Id, patch); - var newTags = target.ProxyTags.ToList(); - newTags.Remove(tagToRemove); - var patch = new MemberPatch {ProxyTags = Partial.Present(newTags.ToArray())}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Removed proxy tags {tagToRemove.ProxyString.AsCode()}."); - } - // Subcommand: bare proxy tag given + await ctx.Reply($"{Emojis.Success} Proxy tags cleared."); + } + // "Sub"command: no arguments; will print proxy tags + else if (!ctx.HasNext(false)) + { + if (target.ProxyTags.Count == 0) + await ctx.Reply("This member does not have any proxy tags."); else + await ctx.Reply($"This member's proxy tags are:\n{target.ProxyTagsString("\n")}"); + } + // Subcommand: "add" + else if (ctx.Match("add", "append")) + { + if (!ctx.HasNext(false)) + throw new PKSyntaxError("You must pass an example proxy to add (eg. `[text]` or `J:text`)."); + + var tagToAdd = ParseProxyTags(ctx.RemainderOrNull(false)); + if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); + if (target.ProxyTags.Contains(tagToAdd)) + throw Errors.ProxyTagAlreadyExists(tagToAdd, target); + if (tagToAdd.ProxyString.Length > Limits.MaxProxyTagLength) + throw new PKError( + $"Proxy tag too long ({tagToAdd.ProxyString.Length} > {Limits.MaxProxyTagLength} characters)."); + + if (!await WarnOnConflict(tagToAdd)) + throw Errors.GenericCancelled(); + + var newTags = target.ProxyTags.ToList(); + newTags.Add(tagToAdd); + var patch = new MemberPatch { ProxyTags = Partial.Present(newTags.ToArray()) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Added proxy tags {tagToAdd.ProxyString.AsCode()}."); + } + // Subcommand: "remove" + else if (ctx.Match("remove", "delete")) + { + if (!ctx.HasNext(false)) + throw new PKSyntaxError("You must pass a proxy tag to remove (eg. `[text]` or `J:text`)."); + + var tagToRemove = ParseProxyTags(ctx.RemainderOrNull(false)); + if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); + if (!target.ProxyTags.Contains(tagToRemove)) + throw Errors.ProxyTagDoesNotExist(tagToRemove, target); + + var newTags = target.ProxyTags.ToList(); + newTags.Remove(tagToRemove); + var patch = new MemberPatch { ProxyTags = Partial.Present(newTags.ToArray()) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Removed proxy tags {tagToRemove.ProxyString.AsCode()}."); + } + // Subcommand: bare proxy tag given + else + { + var requestedTag = ParseProxyTags(ctx.RemainderOrNull(false)); + if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); + + // This is mostly a legacy command, so it's gonna warn if there's + // already more than one proxy tag. + if (target.ProxyTags.Count > 1) { - var requestedTag = ParseProxyTags(ctx.RemainderOrNull(skipFlags: false)); - if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target); - - // This is mostly a legacy command, so it's gonna warn if there's - // already more than one proxy tag. - if (target.ProxyTags.Count > 1) - { - var msg = $"This member already has more than one proxy tag set: {target.ProxyTagsString()}\nDo you want to replace them?"; - if (!await ctx.PromptYesNo(msg)) - throw Errors.GenericCancelled(); - } - - if (!await WarnOnConflict(requestedTag)) + var msg = $"This member already has more than one proxy tag set: {target.ProxyTagsString()}\nDo you want to replace them?"; + if (!await ctx.PromptYesNo(msg, "Replace")) throw Errors.GenericCancelled(); - - var newTags = new[] {requestedTag}; - var patch = new MemberPatch {ProxyTags = Partial.Present(newTags)}; - await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - - await ctx.Reply($"{Emojis.Success} Member proxy tags set to {requestedTag.ProxyString.AsCode()}."); } + + if (!await WarnOnConflict(requestedTag)) + throw Errors.GenericCancelled(); + + var newTags = new[] { requestedTag }; + var patch = new MemberPatch { ProxyTags = Partial.Present(newTags) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member proxy tags set to {requestedTag.ProxyString.AsCode()}."); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Message.cs b/PluralKit.Bot/Commands/Message.cs new file mode 100644 index 00000000..65d47608 --- /dev/null +++ b/PluralKit.Bot/Commands/Message.cs @@ -0,0 +1,322 @@ +#nullable enable +using System.Text; +using System.Text.RegularExpressions; + +using Myriad.Builders; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Rest; +using Myriad.Rest.Exceptions; +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; + +using NodaTime; + +using App.Metrics; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class ProxiedMessage +{ + private static readonly Duration EditTimeout = Duration.FromMinutes(10); + private static readonly Duration ReproxyTimeout = Duration.FromMinutes(1); + + // private readonly IDiscordCache _cache; + private readonly ModelRepository _repo; + private readonly IMetrics _metrics; + + private readonly EmbedService _embeds; + private readonly LogChannelService _logChannel; + private readonly DiscordApiClient _rest; + private readonly WebhookExecutorService _webhookExecutor; + private readonly ProxyService _proxy; + + public ProxiedMessage(EmbedService embeds, + DiscordApiClient rest, IMetrics metrics, ModelRepository repo, ProxyService proxy, + WebhookExecutorService webhookExecutor, LogChannelService logChannel, IDiscordCache cache) + { + _embeds = embeds; + _rest = rest; + _webhookExecutor = webhookExecutor; + _repo = repo; + _logChannel = logChannel; + // _cache = cache; + _metrics = metrics; + _proxy = proxy; + } + + public async Task ReproxyMessage(Context ctx) + { + var msg = await GetMessageToEdit(ctx, ReproxyTimeout, true); + + if (ctx.System.Id != msg.System?.Id) + throw new PKError("Can't reproxy a message sent by a different system."); + + // Get target member ID + var target = await ctx.MatchMember(restrictToSystem: ctx.System.Id); + if (target == null) + throw new PKError("Could not find a member to reproxy the message with."); + + // Fetch members and get the ProxyMember for `target` + List members; + using (_metrics.Measure.Timer.Time(BotMetrics.ProxyMembersQueryTime)) + members = (await _repo.GetProxyMembers(ctx.Author.Id, msg.Message.Guild!.Value)).ToList(); + var match = members.Find(x => x.Id == target.Id); + if (match == null) + throw new PKError("Could not find a member to reproxy the message with."); + + try + { + await _proxy.ExecuteReproxy(ctx.Message, msg.Message, match); + + if (ctx.Guild == null) + await _rest.CreateReaction(ctx.Channel.Id, ctx.Message.Id, new Emoji { Name = Emojis.Success }); + if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages)) + await _rest.DeleteMessage(ctx.Channel.Id, ctx.Message.Id); + } + catch (NotFoundException) + { + throw new PKError("Could not reproxy message."); + } + } + + public async Task EditMessage(Context ctx) + { + var msg = await GetMessageToEdit(ctx, EditTimeout, false); + + if (ctx.System.Id != msg.System?.Id) + throw new PKError("Can't edit a message sent by a different system."); + + if (!ctx.HasNext()) + throw new PKSyntaxError("You need to include the message to edit in."); + + var originalMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid); + if (originalMsg == null) + throw new PKError("Could not edit message."); + + // Check if we should append or prepend + var append = ctx.MatchFlag("append"); + var prepend = ctx.MatchFlag("prepend"); + + // Grab the original message content and new message content + var originalContent = originalMsg.Content; + var newContent = ctx.RemainderOrNull().NormalizeLineEndSpacing(); + + // Append or prepend the new content to the original message content if needed. + // If no flag is supplied, the new contents will completly overwrite the old contents + // If both flags are specified. the message will be prepended AND appended + if (append && prepend) newContent = $"{newContent} {originalContent} {newContent}"; + else if (append) newContent = originalContent + " " + newContent; + else if (prepend) newContent = newContent + " " + originalContent; + + if (newContent.Length > 2000) + throw new PKError("PluralKit cannot proxy messages over 2000 characters in length."); + + try + { + var editedMsg = + await _webhookExecutor.EditWebhookMessage(msg.Message.Channel, msg.Message.Mid, newContent); + + if (ctx.Guild == null) + await _rest.CreateReaction(ctx.Channel.Id, ctx.Message.Id, new Emoji { Name = Emojis.Success }); + + if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages)) + await _rest.DeleteMessage(ctx.Channel.Id, ctx.Message.Id); + + await _logChannel.LogMessage(ctx.MessageContext, msg.Message, ctx.Message, editedMsg, + originalMsg!.Content!); + } + catch (NotFoundException) + { + throw new PKError("Could not edit message."); + } + } + + private async Task GetMessageToEdit(Context ctx, Duration timeout, bool isReproxy) + { + var editType = isReproxy ? "reproxy" : "edit"; + var editTypeAction = isReproxy ? "reproxied" : "edited"; + + // todo: is it correct to get a connection here? + await using var conn = await ctx.Database.Obtain(); + FullMessage? msg = null; + + var (referencedMessage, _) = ctx.MatchMessage(false); + if (referencedMessage != null) + { + msg = await ctx.Repository.GetMessage(conn, referencedMessage.Value); + if (msg == null) + throw new PKError("This is not a message proxied by PluralKit."); + } + + if (msg == null) + { + if (ctx.Guild == null) + throw new PKSyntaxError($"You must use a message link to {editType} messages in DMs."); + + var recent = await FindRecentMessage(ctx, timeout); + if (recent == null) + throw new PKSyntaxError($"Could not find a recent message to {editType}."); + + msg = await ctx.Repository.GetMessage(conn, recent.Mid); + if (msg == null) + throw new PKSyntaxError($"Could not find a recent message to {editType}."); + } + + if (msg.Message.Channel != ctx.Channel.Id) + { + var error = + "The channel where the message was sent does not exist anymore, or you are missing permissions to access it."; + + var channel = await _rest.GetChannelOrNull(msg.Message.Channel); + if (channel == null) + throw new PKError(error); + + if (!await ctx.CheckPermissionsInGuildChannel(channel, + PermissionSet.ViewChannel | PermissionSet.SendMessages + )) + throw new PKError(error); + } + + var msgTimestamp = DiscordUtils.SnowflakeToInstant(msg.Message.Mid); + if (isReproxy && SystemClock.Instance.GetCurrentInstant() - msgTimestamp > timeout) + throw new PKError($"The message is too old to be {editTypeAction}."); + + return msg; + } + + private async Task FindRecentMessage(Context ctx, Duration timeout) + { + var lastMessage = await ctx.Repository.GetLastMessage(ctx.Guild.Id, ctx.Channel.Id, ctx.Author.Id); + if (lastMessage == null) + return null; + + var timestamp = DiscordUtils.SnowflakeToInstant(lastMessage.Mid); + if (SystemClock.Instance.GetCurrentInstant() - timestamp > timeout) + return null; + + return lastMessage; + } + + public async Task GetMessage(Context ctx) + { + var (messageId, _) = ctx.MatchMessage(true); + if (messageId == null) + { + if (!ctx.HasNext()) + throw new PKSyntaxError("You must pass a message ID or link."); + throw new PKSyntaxError($"Could not parse {ctx.PeekArgument().AsCode()} as a message ID or link."); + } + + var isDelete = ctx.Match("delete") || ctx.MatchFlag("delete"); + + var message = await ctx.Database.Execute(c => ctx.Repository.GetMessage(c, messageId.Value)); + if (message == null) + { + if (isDelete) + { + await DeleteCommandMessage(ctx, messageId.Value); + return; + } + + throw Errors.MessageNotFound(messageId.Value); + } + + var showContent = true; + var noShowContentError = "Message deleted or inaccessible."; + + var channel = await _rest.GetChannelOrNull(message.Message.Channel); + if (channel == null) + showContent = false; + else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel)) + showContent = false; + + if (ctx.MatchRaw()) + { + var discordMessage = await _rest.GetMessageOrNull(message.Message.Channel, message.Message.Mid); + if (discordMessage == null || !showContent) + throw new PKError(noShowContentError); + + var content = discordMessage.Content; + if (content == null || content == "") + { + await ctx.Reply("No message content found in that message."); + return; + } + + await ctx.Reply($"```{content}```"); + + if (Regex.IsMatch(content, "```.*```", RegexOptions.Singleline)) + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await ctx.Rest.CreateMessage( + ctx.Channel.Id, + new MessageRequest + { + Content = $"{Emojis.Warn} Message contains codeblocks, raw source sent as an attachment." + }, + new[] { new MultipartFile("message.txt", stream, null) }); + } + + return; + } + + if (isDelete) + { + if (!showContent) + throw new PKError(noShowContentError); + + if (message.System?.Id != ctx.System.Id && message.Message.Sender != ctx.Author.Id) + throw new PKError("You can only delete your own messages."); + + await ctx.Rest.DeleteMessage(message.Message.Channel, message.Message.Mid); + + if (ctx.Channel.Id == message.Message.Channel) + await ctx.Rest.DeleteMessage(ctx.Message); + else + await ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, + new Emoji { Name = Emojis.Success }); + + return; + } + + if (ctx.Match("author") || ctx.MatchFlag("author")) + { + var user = await _rest.GetUser(message.Message.Sender); + var eb = new EmbedBuilder() + .Author(new Embed.EmbedAuthor( + user != null + ? $"{user.Username}#{user.Discriminator}" + : $"Deleted user ${message.Message.Sender}", + IconUrl: user != null ? user.AvatarUrl() : null)) + .Description(message.Message.Sender.ToString()); + + await ctx.Reply( + user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {message.Message.Sender})*", + eb.Build()); + return; + } + + await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent)); + } + + private async Task DeleteCommandMessage(Context ctx, ulong messageId) + { + var message = await ctx.Repository.GetCommandMessage(messageId); + if (message == null) + throw Errors.MessageNotFound(messageId); + + if (message.AuthorId != ctx.Author.Id) + throw new PKError("You can only delete command messages queried by this account."); + + await ctx.Rest.DeleteMessage(message.ChannelId, message.MessageId); + + if (ctx.Guild != null) + await ctx.Rest.DeleteMessage(ctx.Message); + else + await ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new Emoji { Name = Emojis.Success }); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index fb814f84..aecc3d4d 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -1,210 +1,138 @@ -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; using App.Metrics; -using DSharpPlus; - -using Humanizer; +using Myriad.Builders; +using Myriad.Cache; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Types.Requests; +using Myriad.Types; using NodaTime; using PluralKit.Core; -using DSharpPlus.Entities; -namespace PluralKit.Bot { - public class Misc +namespace PluralKit.Bot; + +public class Misc +{ + private readonly BotConfig _botConfig; + private readonly IDiscordCache _cache; + private readonly CpuStatService _cpu; + private readonly IMetrics _metrics; + private readonly ShardInfoService _shards; + private readonly ModelRepository _repo; + + public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ModelRepository repo, ShardInfoService shards, IDiscordCache cache) { - private readonly BotConfig _botConfig; - private readonly IMetrics _metrics; - private readonly CpuStatService _cpu; - private readonly ShardInfoService _shards; - private readonly EmbedService _embeds; - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _botConfig = botConfig; + _metrics = metrics; + _cpu = cpu; + _repo = repo; + _shards = shards; + _cache = cache; + } - public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards, EmbedService embeds, ModelRepository repo, IDatabase db) - { - _botConfig = botConfig; - _metrics = metrics; - _cpu = cpu; - _shards = shards; - _embeds = embeds; - _repo = repo; - _db = db; - } - - public async Task Invite(Context ctx) - { - var clientId = _botConfig.ClientId ?? ctx.Client.CurrentApplication.Id; - var permissions = new Permissions() - .Grant(Permissions.AddReactions) - .Grant(Permissions.AttachFiles) - .Grant(Permissions.EmbedLinks) - .Grant(Permissions.ManageMessages) - .Grant(Permissions.ManageWebhooks) - .Grant(Permissions.ReadMessageHistory) - .Grant(Permissions.SendMessages); - var invite = $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot%20applications.commands&permissions={(long)permissions}"; - await ctx.Reply($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>"); - } - - public async Task Stats(Context ctx) - { - var timeBefore = SystemClock.Instance.GetCurrentInstant(); - var msg = await ctx.Reply($"..."); - var timeAfter = SystemClock.Instance.GetCurrentInstant(); - var apiLatency = timeAfter - timeBefore; - - var messagesReceived = _metrics.Snapshot.GetForContext("Bot").Meters.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.MessagesReceived.Name)?.Value; - var messagesProxied = _metrics.Snapshot.GetForContext("Bot").Meters.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.MessagesProxied.Name)?.Value; - var commandsRun = _metrics.Snapshot.GetForContext("Bot").Meters.FirstOrDefault(m => m.MultidimensionalName == BotMetrics.CommandsRun.Name)?.Value; + public async Task Invite(Context ctx) + { + var clientId = _botConfig.ClientId ?? await _cache.GetOwnUser(); - var totalSystems = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.SystemCount.Name)?.Value ?? 0; - var totalMembers = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.MemberCount.Name)?.Value ?? 0; - var totalGroups = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.GroupCount.Name)?.Value ?? 0; - var totalSwitches = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.SwitchCount.Name)?.Value ?? 0; - var totalMessages = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.MessageCount.Name)?.Value ?? 0; + var permissions = + PermissionSet.AddReactions | + PermissionSet.AttachFiles | + PermissionSet.EmbedLinks | + PermissionSet.ManageMessages | + PermissionSet.ManageWebhooks | + PermissionSet.ReadMessageHistory | + PermissionSet.SendMessages; - var shardId = ctx.Shard.ShardId; - var shardTotal = ctx.Client.ShardClients.Count; - var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count(); - var shardInfo = _shards.GetShardInfo(ctx.Shard); - - var process = Process.GetCurrentProcess(); - var memoryUsage = process.WorkingSet64; + var invite = + $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot%20applications.commands&permissions={(ulong)permissions}"; - var shardUptime = SystemClock.Instance.GetCurrentInstant() - shardInfo.LastConnectionTime; + var botName = _botConfig.IsBetaBot ? "PluralKit Beta" : "PluralKit"; + await ctx.Reply($"{Emojis.Success} Use this link to add {botName} to your server:\n<{invite}>"); + } - var embed = new DiscordEmbedBuilder(); - if (messagesReceived != null) embed.AddField("Messages processed",$"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", true); - if (messagesProxied != null) embed.AddField("Messages proxied", $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", true); - if (commandsRun != null) embed.AddField("Commands executed", $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", true); + public async Task Stats(Context ctx) + { + var timeBefore = SystemClock.Instance.GetCurrentInstant(); + var msg = await ctx.Reply("..."); + var timeAfter = SystemClock.Instance.GetCurrentInstant(); + var apiLatency = timeAfter - timeBefore; - embed - .AddField("Current shard", $"Shard #{shardId} (of {shardTotal} total, {shardUpTotal} are up)", true) - .AddField("Shard uptime", $"{shardUptime.FormatDuration()} ({shardInfo.DisconnectionCount} disconnections)", true) - .AddField("CPU usage", $"{_cpu.LastCpuMeasure:P1}", true) - .AddField("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true) - .AddField("Latency", $"API: {apiLatency.TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency.Milliseconds} ms", true) - .AddField("Total numbers", $"{totalSystems:N0} systems, {totalMembers:N0} members, {totalGroups:N0} groups, {totalSwitches:N0} switches, {totalMessages:N0} messages"); - await msg.ModifyAsync("", embed.Build()); - } + var embed = new EmbedBuilder(); - public async Task PermCheckGuild(Context ctx) - { - DiscordGuild guild; - DiscordMember senderGuildUser = null; + // todo: these will be inaccurate when the bot is actually multi-process - if (ctx.Guild != null && !ctx.HasNext()) - { - guild = ctx.Guild; - senderGuildUser = (DiscordMember)ctx.Author; - } - else - { - var guildIdStr = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a server ID or run this command in a server."); - if (!ulong.TryParse(guildIdStr, out var guildId)) - throw new PKSyntaxError($"Could not parse {guildIdStr.AsCode()} as an ID."); + var messagesReceived = _metrics.Snapshot.GetForContext("Bot").Meters + .FirstOrDefault(m => m.MultidimensionalName == BotMetrics.MessagesReceived.Name)?.Value; + if (messagesReceived != null) + embed.Field(new Embed.Field("Messages processed", + $"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", + true)); - guild = ctx.Client.GetGuild(guildId); - if (guild != null) senderGuildUser = await guild.GetMember(ctx.Author.Id); - if (guild == null || senderGuildUser == null) throw Errors.GuildNotFound(guildId); - } + var messagesProxied = _metrics.Snapshot.GetForContext("Bot").Meters + .FirstOrDefault(m => m.MultidimensionalName == BotMetrics.MessagesProxied.Name)?.Value; + if (messagesProxied != null) + embed.Field(new Embed.Field("Messages proxied", + $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", + true)); - var requiredPermissions = new [] - { - Permissions.AccessChannels, - Permissions.SendMessages, - Permissions.AddReactions, - Permissions.AttachFiles, - Permissions.EmbedLinks, - Permissions.ManageMessages, - Permissions.ManageWebhooks - }; + var commandsRun = _metrics.Snapshot.GetForContext("Bot").Meters + .FirstOrDefault(m => m.MultidimensionalName == BotMetrics.CommandsRun.Name)?.Value; + if (commandsRun != null) + embed.Field(new Embed.Field("Commands executed", + $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", + true)); - // Loop through every channel and group them by sets of permissions missing - var permissionsMissing = new Dictionary>(); - var hiddenChannels = 0; - foreach (var channel in await guild.GetChannelsAsync()) - { - var botPermissions = channel.BotPermissions(); - - var userPermissions = senderGuildUser.PermissionsIn(channel); - if ((userPermissions & Permissions.AccessChannels) == 0) - { - // If the user can't see this channel, don't calculate permissions for it - // (to prevent info-leaking, mostly) - // Instead, count how many hidden channels and show the user (so they don't get confused) - hiddenChannels++; - continue; - } + var isCluster = _botConfig.Cluster != null && _botConfig.Cluster.TotalShards != ctx.Cluster.Shards.Count; - // We use a bitfield so we can set individual permission bits in the loop - // TODO: Rewrite with proper bitfield math - ulong missingPermissionField = 0; - foreach (var requiredPermission in requiredPermissions) - if ((botPermissions & requiredPermission) == 0) - missingPermissionField |= (ulong) requiredPermission; + var counts = await _repo.GetStats(); + var shards = await _shards.GetShards(); - // If we're not missing any permissions, don't bother adding it to the dict - // This means we can check if the dict is empty to see if all channels are proxyable - if (missingPermissionField != 0) - { - permissionsMissing.TryAdd(missingPermissionField, new List()); - permissionsMissing[missingPermissionField].Add(channel); - } - } - - // Generate the output embed - var eb = new DiscordEmbedBuilder() - .WithTitle($"Permission check for **{guild.Name}**"); + var shardInfo = shards.Where(s => s.ShardId == ctx.ShardId).FirstOrDefault(); - if (permissionsMissing.Count == 0) - { - eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(DiscordUtils.Green); - } - else - { - foreach (var (missingPermissionField, channels) in permissionsMissing) - { - // Each missing permission field can have multiple missing channels - // so we extract them all and generate a comma-separated list - var missingPermissionNames = ((Permissions)missingPermissionField).ToPermissionString(); - - var channelsList = string.Join("\n", channels - .OrderBy(c => c.Position) - .Select(c => $"#{c.Name}")); - eb.AddField($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000)); - eb.WithColor(DiscordUtils.Red); - } - } + // todo: if we're running multiple processes, it is not useful to get the CPU/RAM usage of just the current one + var process = Process.GetCurrentProcess(); + var memoryUsage = process.WorkingSet64; - if (hiddenChannels > 0) - eb.WithFooter($"{"channel".ToQuantity(hiddenChannels)} were ignored as you do not have view access to them."); + var now = SystemClock.Instance.GetCurrentInstant().ToUnixTimeSeconds(); + var shardUptime = Duration.FromSeconds(now - shardInfo?.LastConnection ?? 0); - // Send! :) - await ctx.Reply(embed: eb.Build()); - } - - public async Task GetMessage(Context ctx) - { - var word = ctx.PopArgument() ?? throw new PKSyntaxError("You must pass a message ID or link."); + var shardTotal = _botConfig.Cluster?.TotalShards ?? shards.Count(); + int shardClusterTotal = ctx.Cluster.Shards.Count; + var shardUpTotal = shards.Where(x => x.Up).Count(); - ulong messageId; - if (ulong.TryParse(word, out var id)) - messageId = id; - else if (Regex.Match(word, "https://discord(?:app)?.com/channels/\\d+/\\d+/(\\d+)") is Match match && match.Success) - messageId = ulong.Parse(match.Groups[1].Value); - else throw new PKSyntaxError($"Could not parse {word.AsCode()} as a message ID or link."); + embed + .Field(new Embed.Field("Current shard", + $"Shard #{ctx.ShardId} (of {shardTotal} total," + + (isCluster ? $" {shardClusterTotal} in this cluster," : "") + $" {shardUpTotal} are up)" + , true)) + .Field(new Embed.Field("Shard uptime", + $"{shardUptime.FormatDuration()} ({shardInfo?.DisconnectionCount} disconnections)", true)) + .Field(new Embed.Field("CPU usage", $"{_cpu.LastCpuMeasure:P1}", true)) + .Field(new Embed.Field("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true)) + .Field(new Embed.Field("Latency", + $"API: {apiLatency.TotalMilliseconds:F0} ms, shard: {shardInfo?.Latency} ms", + true)); - var message = await _db.Execute(c => _repo.GetMessage(c, messageId)); - if (message == null) throw Errors.MessageNotFound(messageId); + embed.Field(new("Total numbers", $" {counts.SystemCount:N0} systems," + + $" {counts.MemberCount:N0} members," + + $" {counts.GroupCount:N0} groups," + + $" {counts.SwitchCount:N0} switches," + + $" {counts.MessageCount:N0} messages")); - await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(ctx.Shard, message)); - } + embed + .Footer(new(String.Join(" \u2022 ", new[] { + $"PluralKit {BuildInfoService.Version}", + (isCluster ? $"Cluster {_botConfig.Cluster.NodeIndex}" : ""), + "https://github.com/xSke/PluralKit", + "Last restarted:", + }))) + .Timestamp(process.StartTime.ToString("O")); + + await ctx.Rest.EditMessage(msg.ChannelId, msg.Id, + new MessageEditRequest { Content = "", Embeds = new[] { embed.Build() } }); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs b/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs deleted file mode 100644 index ba422e5e..00000000 --- a/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs +++ /dev/null @@ -1,57 +0,0 @@ -using PluralKit.Core; - -namespace PluralKit.Bot -{ - public static class ContextPrivacyExt - { - public static PrivacyLevel PopPrivacyLevel(this Context ctx) - { - if (ctx.Match("public", "show", "shown", "visible")) - return PrivacyLevel.Public; - - if (ctx.Match("private", "hide", "hidden")) - return PrivacyLevel.Private; - - if (!ctx.HasNext()) - throw new PKSyntaxError("You must pass a privacy level (`public` or `private`)"); - - throw new PKSyntaxError($"Invalid privacy level {ctx.PopArgument().AsCode()} (must be `public` or `private`)."); - } - - public static SystemPrivacySubject PopSystemPrivacySubject(this Context ctx) - { - if (!SystemPrivacyUtils.TryParseSystemPrivacy(ctx.PeekArgument(), out var subject)) - throw new PKSyntaxError($"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `members`, `front`, `fronthistory`, `groups`, or `all`)."); - - ctx.PopArgument(); - return subject; - } - - public static MemberPrivacySubject PopMemberPrivacySubject(this Context ctx) - { - if (!MemberPrivacyUtils.TryParseMemberPrivacy(ctx.PeekArgument(), out var subject)) - throw new PKSyntaxError($"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `avatar`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all)."); - - ctx.PopArgument(); - return subject; - } - - public static GroupPrivacySubject PopGroupPrivacySubject(this Context ctx) - { - if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject)) - throw new PKSyntaxError($"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `icon`, `visibility`, or `all)."); - - ctx.PopArgument(); - return subject; - } - - public static bool MatchPrivateFlag(this Context ctx, LookupContext pctx) - { - var privacy = true; - if (ctx.MatchFlag("a", "all")) privacy = false; - if (pctx == LookupContext.ByNonOwner && !privacy) throw Errors.LookupNotAllowed; - - return privacy; - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Random.cs b/PluralKit.Bot/Commands/Random.cs index 6c154cbc..b7ea811d 100644 --- a/PluralKit.Bot/Commands/Random.cs +++ b/PluralKit.Bot/Commands/Random.cs @@ -1,79 +1,74 @@ -using System.Linq; -using System.Threading.Tasks; - using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Random { - public class Random + private readonly EmbedService _embeds; + + private readonly global::System.Random randGen = new(); + + public Random(EmbedService embeds) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; + _embeds = embeds; + } - private readonly global::System.Random randGen = new global::System.Random(); + // todo: get postgresql to return one random member/group instead of querying all members/groups - public Random(EmbedService embeds, IDatabase db, ModelRepository repo) - { - _embeds = embeds; - _db = db; - _repo = repo; - } + public async Task Member(Context ctx) + { + ctx.CheckSystem(); - // todo: get postgresql to return one random member/group instead of querying all members/groups + var members = await ctx.Repository.GetSystemMembers(ctx.System.Id).ToListAsync(); - public async Task Member(Context ctx) - { - ctx.CheckSystem(); + if (!ctx.MatchFlag("all", "a")) + members = members.Where(m => m.MemberVisibility == PrivacyLevel.Public).ToList(); - var members = await _db.Execute(c => - { - if (ctx.MatchFlag("all", "a")) - return _repo.GetSystemMembers(c, ctx.System.Id); - return _repo.GetSystemMembers(c, ctx.System.Id) - .Where(m => m.MemberVisibility == PrivacyLevel.Public); - }).ToListAsync(); - - if (members == null || !members.Any()) - throw new PKError("Your system has no members! Please create at least one member before using this command."); + if (members == null || !members.Any()) + throw new PKError( + "Your system has no members! Please create at least one member before using this command."); - var randInt = randGen.Next(members.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); - } + var randInt = randGen.Next(members.Count); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, + ctx.LookupContextFor(ctx.System.Id), ctx.Zone)); + } - public async Task Group(Context ctx) - { - ctx.CheckSystem(); + public async Task Group(Context ctx) + { + ctx.CheckSystem(); - var groups = await _db.Execute(c => c.QueryGroupList(ctx.System.Id)); - if (!ctx.MatchFlag("all", "a")) - groups = groups.Where(g => g.Visibility == PrivacyLevel.Public); + var groups = await ctx.Repository.GetSystemGroups(ctx.System.Id).ToListAsync(); + if (!ctx.MatchFlag("all", "a")) + groups = groups.Where(g => g.Visibility == PrivacyLevel.Public).ToList(); - if (groups == null || !groups.Any()) - throw new PKError("Your system has no groups! Please create at least one group before using this command."); + if (groups == null || !groups.Any()) + throw new PKError( + "Your system has no groups! Please create at least one group before using this command."); - var randInt = randGen.Next(groups.Count()); - await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, ctx.System, groups.ToArray()[randInt])); - } + var randInt = randGen.Next(groups.Count()); + await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, ctx.System, groups.ToArray()[randInt])); + } - public async Task GroupMember(Context ctx, PKGroup group) - { - var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(group.System)); - opts.GroupFilter = group.Id; + public async Task GroupMember(Context ctx, PKGroup group) + { + ctx.CheckOwnGroup(group); - await using var conn = await _db.Obtain(); - var members = await conn.QueryMemberList(ctx.System.Id, opts.ToQueryOptions()); + var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(group.System)); + opts.GroupFilter = group.Id; - if (members == null || !members.Any()) - throw new PKError("This group has no members! Please add at least one member to this group before using this command."); + var members = await ctx.Database.Execute(conn => conn.QueryMemberList(ctx.System.Id, opts.ToQueryOptions())); - if (!ctx.MatchFlag("all", "a")) - members = members.Where(g => g.MemberVisibility == PrivacyLevel.Public); + if (members == null || !members.Any()) + throw new PKError( + "This group has no members! Please add at least one member to this group before using this command."); - var ms = members.ToList(); + if (!ctx.MatchFlag("all", "a")) + members = members.Where(g => g.MemberVisibility == PrivacyLevel.Public); - var randInt = randGen.Next(ms.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); - } + var ms = members.ToList(); + + var randInt = randGen.Next(ms.Count); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.Guild, + ctx.LookupContextFor(ctx.System.Id), ctx.Zone)); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index 86bbd4ff..5cf8bb46 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -1,60 +1,75 @@ -using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.Entities; +using Myriad.Builders; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class ServerConfig { - public class ServerConfig + private readonly IDiscordCache _cache; + + public ServerConfig(IDiscordCache cache) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly LoggerCleanService _cleanService; - public ServerConfig(LoggerCleanService cleanService, IDatabase db, ModelRepository repo) + _cache = cache; + } + + public async Task SetLogChannel(Context ctx) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + var settings = await ctx.Repository.GetGuild(ctx.Guild.Id); + + if (await ctx.MatchClear("the server log channel")) { - _cleanService = cleanService; - _db = db; - _repo = repo; + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogChannel = null }); + await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared."); + return; } - public async Task SetLogChannel(Context ctx) + if (!ctx.HasNext()) { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); - - if (await ctx.MatchClear("the server log channel")) + if (settings.LogChannel == null) { - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, new GuildPatch {LogChannel = null})); - await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared."); + await ctx.Reply("This server does not have a log channel set."); return; } - - if (!ctx.HasNext()) - throw new PKSyntaxError("You must pass a #channel to set, or `clear` to clear it."); - - DiscordChannel channel = null; - var channelString = ctx.PeekArgument(); - channel = await ctx.MatchChannel(); - if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); - var patch = new GuildPatch {LogChannel = channel.Id}; - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, patch)); - await ctx.Reply($"{Emojis.Success} Proxy logging channel set to #{channel.Name}."); + await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>."); + return; } - public async Task SetLogEnabled(Context ctx, bool enable) - { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); + Channel channel = null; + var channelString = ctx.PeekArgument(); + channel = await ctx.MatchChannel(); + if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); + if (channel.Type != Channel.ChannelType.GuildText) + throw new PKError("PluralKit cannot log messages to this type of channel."); - var affectedChannels = new List(); - if (ctx.Match("all")) - affectedChannels = (await ctx.Guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text).ToList(); - else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); - else while (ctx.HasNext()) + var perms = await _cache.PermissionsIn(channel.Id); + if (!perms.HasFlag(PermissionSet.SendMessages)) + throw new PKError("PluralKit is missing **Send Messages** permissions in the new log channel."); + if (!perms.HasFlag(PermissionSet.EmbedLinks)) + throw new PKError("PluralKit is missing **Embed Links** permissions in the new log channel."); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogChannel = channel.Id }); + await ctx.Reply($"{Emojis.Success} Proxy logging channel set to <#{channel.Id}>."); + } + + public async Task SetLogEnabled(Context ctx, bool enable) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + var affectedChannels = new List(); + if (ctx.Match("all")) + affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)) + .Where(x => x.Type == Channel.ChannelType.GuildText).ToList(); + else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); + else + while (ctx.HasNext()) { var channelString = ctx.PeekArgument(); var channel = await ctx.MatchChannel(); @@ -62,136 +77,147 @@ namespace PluralKit.Bot affectedChannels.Add(channel); } - ulong? logChannel = null; - await using (var conn = await _db.Obtain()) - { - var config = await _repo.GetGuild(conn, ctx.Guild.Id); - logChannel = config.LogChannel; - var blacklist = config.LogBlacklist.ToHashSet(); - if (enable) - blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); - else - blacklist.UnionWith(affectedChannels.Select(c => c.Id)); - - var patch = new GuildPatch {LogBlacklist = blacklist.ToArray()}; - await _repo.UpsertGuild(conn, ctx.Guild.Id, patch); - } + ulong? logChannel = null; + var config = await ctx.Repository.GetGuild(ctx.Guild.Id); + logChannel = config.LogChannel; - await ctx.Reply( - $"{Emojis.Success} Message logging for the given channels {(enable ? "enabled" : "disabled")}." + - (logChannel == null ? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `pk;log channel #your-log-channel`." : "")); + var blacklist = config.LogBlacklist.ToHashSet(); + if (enable) + blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); + else + blacklist.UnionWith(affectedChannels.Select(c => c.Id)); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() }); + + await ctx.Reply( + $"{Emojis.Success} Message logging for the given channels {(enable ? "enabled" : "disabled")}." + + (logChannel == null + ? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `pk;log channel #your-log-channel`." + : "")); + } + + public async Task ShowBlacklisted(Context ctx) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + var blacklist = await ctx.Repository.GetGuild(ctx.Guild.Id); + + // Resolve all channels from the cache and order by position + var channels = (await Task.WhenAll(blacklist.Blacklist + .Select(id => _cache.TryGetChannel(id)))) + .Where(c => c != null) + .OrderBy(c => c.Position) + .ToList(); + + if (channels.Count == 0) + { + await ctx.Reply("This server has no blacklisted channels."); + return; } - public async Task ShowBlacklisted(Context ctx) - { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); - - var blacklist = await _db.Execute(c => _repo.GetGuild(c, ctx.Guild.Id)); - - // Resolve all channels from the cache and order by position - var channels = blacklist.Blacklist - .Select(id => ctx.Guild.GetChannel(id)) - .Where(c => c != null) - .OrderBy(c => c.Position) - .ToList(); - - if (channels.Count == 0) + await ctx.Paginate(channels.ToAsyncEnumerable(), channels.Count, 25, + $"Blacklisted channels for {ctx.Guild.Name}", + null, + async (eb, l) => { - await ctx.Reply($"This server has no blacklisted channels."); - return; - } + async Task CategoryName(ulong? id) => + id != null ? (await _cache.GetChannel(id.Value)).Name : "(no category)"; - await ctx.Paginate(channels.ToAsyncEnumerable(), channels.Count, 25, - $"Blacklisted channels for {ctx.Guild.Name}", - (eb, l) => + ulong? lastCategory = null; + + var fieldValue = new StringBuilder(); + foreach (var channel in l) { - DiscordChannel lastCategory = null; - - var fieldValue = new StringBuilder(); - foreach (var channel in l) + if (lastCategory != channel!.ParentId && fieldValue.Length > 0) { - if (lastCategory != channel.Parent && fieldValue.Length > 0) - { - eb.AddField(lastCategory?.Name ?? "(no category)", fieldValue.ToString()); - fieldValue.Clear(); - } - else fieldValue.Append("\n"); - - fieldValue.Append(channel.Mention); - lastCategory = channel.Parent; + eb.Field(new Embed.Field(await CategoryName(lastCategory), fieldValue.ToString())); + fieldValue.Clear(); + } + else + { + fieldValue.Append("\n"); } - eb.AddField(lastCategory?.Name ?? "(no category)", fieldValue.ToString()); + fieldValue.Append(channel.Mention()); + lastCategory = channel.ParentId; + } - return Task.CompletedTask; - }); - } + eb.Field(new Embed.Field(await CategoryName(lastCategory), fieldValue.ToString())); + }); + } - public async Task SetBlacklisted(Context ctx, bool shouldAdd) - { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); + public async Task SetBlacklisted(Context ctx, bool shouldAdd) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - var affectedChannels = new List(); - if (ctx.Match("all")) - affectedChannels = (await ctx.Guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text).ToList(); - else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); - else while (ctx.HasNext()) + var affectedChannels = new List(); + if (ctx.Match("all")) + affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)) + .Where(x => x.Type == Channel.ChannelType.GuildText).ToList(); + else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); + else + while (ctx.HasNext()) { var channelString = ctx.PeekArgument(); var channel = await ctx.MatchChannel(); if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); affectedChannels.Add(channel); } - - await using (var conn = await _db.Obtain()) - { - var guild = await _repo.GetGuild(conn, ctx.Guild.Id); - var blacklist = guild.Blacklist.ToHashSet(); - if (shouldAdd) - blacklist.UnionWith(affectedChannels.Select(c => c.Id)); - else - blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); - - var patch = new GuildPatch {Blacklist = blacklist.ToArray()}; - await _repo.UpsertGuild(conn, ctx.Guild.Id, patch); - } - await ctx.Reply($"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist."); - } + var guild = await ctx.Repository.GetGuild(ctx.Guild.Id); - public async Task SetLogCleanup(Context ctx) + var blacklist = guild.Blacklist.ToHashSet(); + if (shouldAdd) + blacklist.UnionWith(affectedChannels.Select(c => c.Id)); + else + blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { Blacklist = blacklist.ToArray() }); + + await ctx.Reply( + $"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist."); + } + + public async Task SetLogCleanup(Context ctx) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + var botList = string.Join(", ", LoggerCleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant())); + + var guild = await ctx.Repository.GetGuild(ctx.Guild.Id); + + bool newValue; + if (ctx.Match("enable", "on", "yes")) { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); - - var botList = string.Join(", ", _cleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant())); - - bool newValue; - if (ctx.Match("enable", "on", "yes")) - newValue = true; - else if (ctx.Match("disable", "off", "no")) - newValue = false; - else - { - var eb = new DiscordEmbedBuilder() - .WithTitle("Log cleanup settings") - .AddField("Supported bots", botList); - - var guildCfg = await _db.Execute(c => _repo.GetGuild(c, ctx.Guild.Id)); - if (guildCfg.LogCleanupEnabled) - eb.WithDescription("Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`."); - else - eb.WithDescription("Log cleanup is currently **off** for this server. To enable it, type `pk;logclean on`."); - await ctx.Reply(embed: eb.Build()); - return; - } - - var patch = new GuildPatch {LogCleanupEnabled = newValue}; - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, patch)); - - if (newValue) - await ctx.Reply($"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts."); - else - await ctx.Reply($"{Emojis.Success} Log cleanup has been **disabled** for this server."); + newValue = true; } + else if (ctx.Match("disable", "off", "no")) + { + newValue = false; + } + else + { + var eb = new EmbedBuilder() + .Title("Log cleanup settings") + .Field(new Embed.Field("Supported bots", botList)); + + var guildCfg = await ctx.Repository.GetGuild(ctx.Guild.Id); + if (guildCfg.LogCleanupEnabled) + eb.Description( + "Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`."); + else + eb.Description( + "Log cleanup is currently **off** for this server. To enable it, type `pk;logclean on`."); + await ctx.Reply(embed: eb.Build()); + return; + } + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogCleanupEnabled = newValue }); + + if (newValue) + await ctx.Reply( + $"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts."); + else + await ctx.Reply($"{Emojis.Success} Log cleanup has been **disabled** for this server."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Switch.cs b/PluralKit.Bot/Commands/Switch.cs index a5094e21..751cf0a2 100644 --- a/PluralKit.Bot/Commands/Switch.cs +++ b/PluralKit.Bot/Commands/Switch.cs @@ -1,155 +1,203 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using DSharpPlus.Entities; - using NodaTime; using NodaTime.TimeZones; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Switch { - public class Switch + public async Task SwitchDo(Context ctx) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + ctx.CheckSystem(); - public Switch(IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - } - - public async Task SwitchDo(Context ctx) - { - ctx.CheckSystem(); - - var members = await ctx.ParseMemberList(ctx.System.Id); - await DoSwitchCommand(ctx, members); - } - public async Task SwitchOut(Context ctx) - { - ctx.CheckSystem(); - - // Switch with no members = switch-out - await DoSwitchCommand(ctx, new PKMember[] { }); - } - - private async Task DoSwitchCommand(Context ctx, ICollection members) - { - // Make sure there are no dupes in the list - // We do this by checking if removing duplicate member IDs results in a list of different length - if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers; - - // Find the last switch and its members if applicable - await using var conn = await _db.Obtain(); - var lastSwitch = await _repo.GetLatestSwitch(conn, ctx.System.Id); - if (lastSwitch != null) - { - var lastSwitchMembers = _repo.GetSwitchMembers(conn, lastSwitch.Id); - // Make sure the requested switch isn't identical to the last one - if (await lastSwitchMembers.Select(m => m.Id).SequenceEqualAsync(members.Select(m => m.Id).ToAsyncEnumerable())) - throw Errors.SameSwitch(members, ctx.LookupContextFor(ctx.System)); - } - - await _repo.AddSwitch(conn, ctx.System.Id, members.Select(m => m.Id).ToList()); - - if (members.Count == 0) - await ctx.Reply($"{Emojis.Success} Switch-out registered."); - else - await ctx.Reply($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.NameFor(ctx)))}."); - } - - public async Task SwitchMove(Context ctx) - { - ctx.CheckSystem(); - - var timeToMove = ctx.RemainderOrNull() ?? throw new PKSyntaxError("Must pass a date or time to move the switch to."); - var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.System.UiTz ?? "UTC"); - - var result = DateUtils.ParseDateTime(timeToMove, true, tz); - if (result == null) throw Errors.InvalidDateTime(timeToMove); - - await using var conn = await _db.Obtain(); - - var time = result.Value; - if (time.ToInstant() > SystemClock.Instance.GetCurrentInstant()) throw Errors.SwitchTimeInFuture; - - // Fetch the last two switches for the system to do bounds checking on - var lastTwoSwitches = await _repo.GetSwitches(conn, ctx.System.Id).Take(2).ToListAsync(); - - // If we don't have a switch to move, don't bother - if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches; - - // If there's a switch *behind* the one we move, we check to make srue we're not moving the time further back than that - if (lastTwoSwitches.Count == 2) - { - if (lastTwoSwitches[1].Timestamp > time.ToInstant()) - throw Errors.SwitchMoveBeforeSecondLast(lastTwoSwitches[1].Timestamp.InZone(tz)); - } - - // Now we can actually do the move, yay! - // But, we do a prompt to confirm. - var lastSwitchMembers = _repo.GetSwitchMembers(conn, lastTwoSwitches[0].Id); - var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync()); - var lastSwitchTimeStr = lastTwoSwitches[0].Timestamp.FormatZoned(ctx.System); - var lastSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp).FormatDuration(); - var newSwitchTimeStr = time.FormatZoned(); - var newSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - time.ToInstant()).FormatDuration(); - - // yeet - var msg = $"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from {lastSwitchTimeStr} ({lastSwitchDeltaStr} ago) to {newSwitchTimeStr} ({newSwitchDeltaStr} ago). Is this OK?"; - if (!await ctx.PromptYesNo(msg)) throw Errors.SwitchMoveCancelled; - - // aaaand *now* we do the move - await _repo.MoveSwitch(conn, lastTwoSwitches[0].Id, time.ToInstant()); - await ctx.Reply($"{Emojis.Success} Switch moved to {newSwitchTimeStr} ({newSwitchDeltaStr} ago)."); - } - - public async Task SwitchDelete(Context ctx) - { - ctx.CheckSystem(); - - if (ctx.Match("all", "clear") || ctx.MatchFlag("all", "clear")) - { - // Subcommand: "delete all" - var purgeMsg = $"{Emojis.Warn} This will delete *all registered switches* in your system. Are you sure you want to proceed?"; - if (!await ctx.PromptYesNo(purgeMsg)) - throw Errors.GenericCancelled(); - await _db.Execute(c => _repo.DeleteAllSwitches(c, ctx.System.Id)); - await ctx.Reply($"{Emojis.Success} Cleared system switches!"); - return; - } - - await using var conn = await _db.Obtain(); - - // Fetch the last two switches for the system to do bounds checking on - var lastTwoSwitches = await _repo.GetSwitches(conn, ctx.System.Id).Take(2).ToListAsync(); - if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches; - - var lastSwitchMembers = _repo.GetSwitchMembers(conn, lastTwoSwitches[0].Id); - var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync()); - var lastSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp).FormatDuration(); - - string msg; - if (lastTwoSwitches.Count == 1) - { - msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?"; - } - else - { - var secondSwitchMembers = _repo.GetSwitchMembers(conn, lastTwoSwitches[1].Id); - var secondSwitchMemberStr = string.Join(", ", await secondSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync()); - var secondSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp).FormatDuration(); - msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?"; - } - - if (!await ctx.PromptYesNo(msg)) throw Errors.SwitchDeleteCancelled; - await _repo.DeleteSwitch(conn, lastTwoSwitches[0].Id); - - await ctx.Reply($"{Emojis.Success} Switch deleted."); - } + var members = await ctx.ParseMemberList(ctx.System.Id); + await DoSwitchCommand(ctx, members); } -} + + public async Task SwitchOut(Context ctx) + { + ctx.CheckSystem(); + + // Switch with no members = switch-out + await DoSwitchCommand(ctx, new PKMember[] { }); + } + + private async Task DoSwitchCommand(Context ctx, ICollection members) + { + // Make sure there are no dupes in the list + // We do this by checking if removing duplicate member IDs results in a list of different length + if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers; + if (members.Count > Limits.MaxSwitchMemberCount) + throw new PKError( + $"Switch contains too many members ({members.Count} > {Limits.MaxSwitchMemberCount} members)."); + + // Find the last switch and its members if applicable + await using var conn = await ctx.Database.Obtain(); + var lastSwitch = await ctx.Repository.GetLatestSwitch(ctx.System.Id); + if (lastSwitch != null) + { + var lastSwitchMembers = ctx.Repository.GetSwitchMembers(conn, lastSwitch.Id); + // Make sure the requested switch isn't identical to the last one + if (await lastSwitchMembers.Select(m => m.Id) + .SequenceEqualAsync(members.Select(m => m.Id).ToAsyncEnumerable())) + throw Errors.SameSwitch(members, ctx.LookupContextFor(ctx.System.Id)); + } + + await ctx.Repository.AddSwitch(conn, ctx.System.Id, members.Select(m => m.Id).ToList()); + + if (members.Count == 0) + await ctx.Reply($"{Emojis.Success} Switch-out registered."); + else + await ctx.Reply( + $"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.NameFor(ctx)))}."); + } + + public async Task SwitchMove(Context ctx) + { + ctx.CheckSystem(); + + var timeToMove = ctx.RemainderOrNull() ?? + throw new PKSyntaxError("Must pass a date or time to move the switch to."); + var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.Config?.UiTz ?? "UTC"); + + var result = DateUtils.ParseDateTime(timeToMove, true, tz); + if (result == null) throw Errors.InvalidDateTime(timeToMove); + + + var time = result.Value; + if (time.ToInstant() > SystemClock.Instance.GetCurrentInstant()) throw Errors.SwitchTimeInFuture; + + // Fetch the last two switches for the system to do bounds checking on + var lastTwoSwitches = await ctx.Repository.GetSwitches(ctx.System.Id).Take(2).ToListAsync(); + + // If we don't have a switch to move, don't bother + if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches; + + // If there's a switch *behind* the one we move, we check to make sure we're not moving the time further back than that + if (lastTwoSwitches.Count == 2) + if (lastTwoSwitches[1].Timestamp > time.ToInstant()) + throw Errors.SwitchMoveBeforeSecondLast(lastTwoSwitches[1].Timestamp.InZone(tz)); + + // Now we can actually do the move, yay! + // But, we do a prompt to confirm. + var lastSwitchMembers = ctx.Database.Execute(conn => ctx.Repository.GetSwitchMembers(conn, lastTwoSwitches[0].Id)); + var lastSwitchMemberStr = + string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync()); + var lastSwitchTime = lastTwoSwitches[0].Timestamp.ToUnixTimeSeconds(); // .FormatZoned(ctx.System) + var lastSwitchDeltaStr = + (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp).FormatDuration(); + var newSwitchTime = time.ToInstant().ToUnixTimeSeconds(); + var newSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - time.ToInstant()).FormatDuration(); + + // yeet + var msg = + $"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from ({lastSwitchDeltaStr} ago) to ({newSwitchDeltaStr} ago). Is this OK?"; + if (!await ctx.PromptYesNo(msg, "Move Switch")) throw Errors.SwitchMoveCancelled; + + // aaaand *now* we do the move + await ctx.Repository.MoveSwitch(lastTwoSwitches[0].Id, time.ToInstant()); + await ctx.Reply($"{Emojis.Success} Switch moved to ({newSwitchDeltaStr} ago)."); + } + + public async Task SwitchEdit(Context ctx) + { + ctx.CheckSystem(); + + var members = await ctx.ParseMemberList(ctx.System.Id); + await DoEditCommand(ctx, members); + } + + public async Task SwitchEditOut(Context ctx) + { + ctx.CheckSystem(); + await DoEditCommand(ctx, new PKMember[] { }); + } + + public async Task DoEditCommand(Context ctx, ICollection members) + { + // Make sure there are no dupes in the list + // We do this by checking if removing duplicate member IDs results in a list of different length + if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers; + + // Find the switch to edit + await using var conn = await ctx.Database.Obtain(); + var lastSwitch = await ctx.Repository.GetLatestSwitch(ctx.System.Id); + // Make sure there's at least one switch + if (lastSwitch == null) throw Errors.NoRegisteredSwitches; + var lastSwitchMembers = ctx.Repository.GetSwitchMembers(conn, lastSwitch.Id); + // Make sure switch isn't being edited to have the members it already does + if (await lastSwitchMembers.Select(m => m.Id) + .SequenceEqualAsync(members.Select(m => m.Id).ToAsyncEnumerable())) + throw Errors.SameSwitch(members, ctx.LookupContextFor(ctx.System.Id)); + + // Send a prompt asking the user to confirm the switch + var lastSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastSwitch.Timestamp).FormatDuration(); + var lastSwitchMemberStr = + string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync()); + var newSwitchMemberStr = string.Join(", ", members.Select(m => m.NameFor(ctx))); + + string msg; + if (members.Count == 0) + msg = $"{Emojis.Warn} This will turn the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) into a switch-out. Is this okay?"; + else + msg = $"{Emojis.Warn} This will change the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) to {newSwitchMemberStr}. Is this okay?"; + if (!await ctx.PromptYesNo(msg, "Edit")) throw Errors.SwitchEditCancelled; + + // Actually edit the switch + await ctx.Repository.EditSwitch(conn, lastSwitch.Id, members.Select(m => m.Id).ToList()); + + // Tell the user the edit suceeded + if (members.Count == 0) + await ctx.Reply($"{Emojis.Success} Switch edited. The latest switch is now a switch-out."); + else + await ctx.Reply($"{Emojis.Success} Switch edited. Current fronter is now {newSwitchMemberStr}."); + } + + public async Task SwitchDelete(Context ctx) + { + ctx.CheckSystem(); + + if (ctx.Match("all", "clear") || ctx.MatchFlag("all", "clear")) + { + // Subcommand: "delete all" + var purgeMsg = + $"{Emojis.Warn} This will delete *all registered switches* in your system. Are you sure you want to proceed?"; + if (!await ctx.PromptYesNo(purgeMsg, "Clear Switches")) + throw Errors.GenericCancelled(); + await ctx.Repository.DeleteAllSwitches(ctx.System.Id); + await ctx.Reply($"{Emojis.Success} Cleared system switches!"); + return; + } + + // Fetch the last two switches for the system to do bounds checking on + var lastTwoSwitches = await ctx.Repository.GetSwitches(ctx.System.Id).Take(2).ToListAsync(); + if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches; + + var lastSwitchMembers = ctx.Database.Execute(conn => ctx.Repository.GetSwitchMembers(conn, lastTwoSwitches[0].Id)); + var lastSwitchMemberStr = + string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync()); + var lastSwitchDeltaStr = + (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp).FormatDuration(); + + string msg; + if (lastTwoSwitches.Count == 1) + { + msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?"; + } + else + { + var secondSwitchMembers = ctx.Database.Execute(conn => ctx.Repository.GetSwitchMembers(conn, lastTwoSwitches[1].Id)); + var secondSwitchMemberStr = + string.Join(", ", await secondSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync()); + var secondSwitchDeltaStr = (SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp) + .FormatDuration(); + msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?"; + } + + if (!await ctx.PromptYesNo(msg, "Delete Switch")) throw Errors.SwitchDeleteCancelled; + await ctx.Repository.DeleteSwitch(lastTwoSwitches[0].Id); + + await ctx.Reply($"{Emojis.Success} Switch deleted."); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/System.cs b/PluralKit.Bot/Commands/System.cs index d531196d..4046220d 100644 --- a/PluralKit.Bot/Commands/System.cs +++ b/PluralKit.Bot/Commands/System.cs @@ -1,45 +1,36 @@ -using System.Threading.Tasks; - using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class System { - public class System + private readonly EmbedService _embeds; + + public System(EmbedService embeds, ModelRepository repo) { - private readonly EmbedService _embeds; - private readonly IDatabase _db; - private readonly ModelRepository _repo; - - public System(EmbedService embeds, IDatabase db, ModelRepository repo) - { - _embeds = embeds; - _db = db; - _repo = repo; - } - - public async Task Query(Context ctx, PKSystem system) { - if (system == null) throw Errors.NoSystemError; - - await ctx.Reply(embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system))); - } - - public async Task New(Context ctx) - { - ctx.CheckNoSystem(); - - var systemName = ctx.RemainderOrNull(); - if (systemName != null && systemName.Length > Limits.MaxSystemNameLength) - throw Errors.SystemNameTooLongError(systemName.Length); - - var system = _db.Execute(async c => - { - var system = await _repo.CreateSystem(c, systemName); - await _repo.AddAccount(c, system.Id, ctx.Author.Id); - return system; - }); - - // TODO: better message, perhaps embed like in groups? - await ctx.Reply($"{Emojis.Success} Your system has been created. Type `pk;system` to view it, and type `pk;system help` for more information about commands you can use now. Now that you have that set up, check out the getting started guide on setting up members and proxies: "); - } + _embeds = embeds; } -} + + public async Task Query(Context ctx, PKSystem system) + { + if (system == null) throw Errors.NoSystemError; + + await ctx.Reply(embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system.Id))); + } + + public async Task New(Context ctx) + { + ctx.CheckNoSystem(); + + var systemName = ctx.RemainderOrNull(); + if (systemName != null && systemName.Length > Limits.MaxSystemNameLength) + throw Errors.StringTooLongError("System name", systemName.Length, Limits.MaxSystemNameLength); + + var system = await ctx.Repository.CreateSystem(systemName); + await ctx.Repository.AddAccount(system.Id, ctx.Author.Id); + + // TODO: better message, perhaps embed like in groups? + await ctx.Reply( + $"{Emojis.Success} Your system has been created. Type `pk;system` to view it, and type `pk;system help` for more information about commands you can use now. Now that you have that set up, check out the getting started guide on setting up members and proxies: "); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index a4f641af..1ecb3ca1 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -1,404 +1,687 @@ -using System; -using System.Linq; -using System.Threading.Tasks; +using System.Text; +using System.Text.RegularExpressions; -using Dapper; +using Myriad.Builders; +using Myriad.Rest.Exceptions; +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; -using DSharpPlus; -using DSharpPlus.Entities; - -using NodaTime; -using NodaTime.Text; -using NodaTime.TimeZones; +using Newtonsoft.Json; using PluralKit.Core; -using Sentry.Protocol; +namespace PluralKit.Bot; -namespace PluralKit.Bot +public class SystemEdit { - public class SystemEdit - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + private readonly HttpClient _client; + private readonly DataFileService _dataFiles; + private readonly PrivateChannelService _dmCache; - public SystemEdit(IDatabase db, ModelRepository repo) + public SystemEdit(DataFileService dataFiles, HttpClient client, PrivateChannelService dmCache) + { + _dataFiles = dataFiles; + _client = client; + _dmCache = dmCache; + } + + public async Task Name(Context ctx, PKSystem target) + { + var isOwnSystem = target.Id == ctx.System?.Id; + + var noNameSetMessage = $"{(isOwnSystem ? "Your" : "This")} system does not have a name set."; + if (isOwnSystem) + noNameSetMessage += " Type `pk;system name ` to set one."; + + if (ctx.MatchRaw()) { - _db = db; - _repo = repo; + if (target.Name != null) + await ctx.Reply($"```\n{target.Name}\n```"); + else + await ctx.Reply(noNameSetMessage); + return; } - public async Task Name(Context ctx) + if (!ctx.HasNext(false)) { - ctx.CheckSystem(); + if (target.Name != null) + await ctx.Reply( + $"{(isOwnSystem ? "Your" : "This")} system's name is currently **{target.Name}**." + + (isOwnSystem ? " Type `pk;system name -clear` to clear it." : "")); + else + await ctx.Reply(noNameSetMessage); + return; + } - if (await ctx.MatchClear("your system's name")) - { - var clearPatch = new SystemPatch {Name = null}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, clearPatch)); + ctx.CheckSystem().CheckOwnSystem(target); - await ctx.Reply($"{Emojis.Success} System name cleared."); - return; - } + if (await ctx.MatchClear("your system's name")) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Name = null }); + + await ctx.Reply($"{Emojis.Success} System name cleared."); + } + else + { + var newSystemName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + + if (newSystemName.Length > Limits.MaxSystemNameLength) + throw Errors.StringTooLongError("System name", newSystemName.Length, Limits.MaxSystemNameLength); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Name = newSystemName }); - var newSystemName = ctx.RemainderOrNull(); - if (newSystemName == null) - { - if (ctx.System.Name != null) - await ctx.Reply($"Your system's name is currently **{ctx.System.Name}**. Type `pk;system name -clear` to clear it."); - else - await ctx.Reply("Your system currently does not have a name. Type `pk;system name ` to set one."); - return; - } - - if (newSystemName != null && newSystemName.Length > Limits.MaxSystemNameLength) - throw Errors.SystemNameTooLongError(newSystemName.Length); - - var patch = new SystemPatch {Name = newSystemName}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, patch)); - await ctx.Reply($"{Emojis.Success} System name changed."); } - - public async Task Description(Context ctx) { - ctx.CheckSystem(); + } - if (await ctx.MatchClear("your system's description")) - { - var patch = new SystemPatch {Description = null}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, patch)); - - await ctx.Reply($"{Emojis.Success} System description cleared."); - return; - } - - var newDescription = ctx.RemainderOrNull()?.NormalizeLineEndSpacing(); - if (newDescription == null) - { - if (ctx.System.Description == null) - await ctx.Reply("Your system does not have a description set. To set one, type `pk;s description `."); - else if (ctx.MatchFlag("r", "raw")) - await ctx.Reply($"```\n{ctx.System.Description}\n```"); - else - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("System description") - .WithDescription(ctx.System.Description) - .WithFooter("To print the description with formatting, type `pk;s description -raw`. To clear it, type `pk;s description -clear`. To change it, type `pk;s description `.") - .Build()); - } - else - { - if (newDescription.Length > Limits.MaxDescriptionLength) throw Errors.DescriptionTooLongError(newDescription.Length); - - var patch = new SystemPatch {Description = newDescription}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, patch)); - - await ctx.Reply($"{Emojis.Success} System description changed."); - } - } - - public async Task Tag(Context ctx) + public async Task Description(Context ctx, PKSystem target) + { + ctx.CheckSystemPrivacy(target.Id, target.DescriptionPrivacy); + + var isOwnSystem = target.Id == ctx.System?.Id; + + var noDescriptionSetMessage = "This system does not have a description set."; + if (isOwnSystem) + noDescriptionSetMessage += " To set one, type `pk;s description `."; + + if (ctx.MatchRaw()) { - ctx.CheckSystem(); - - if (await ctx.MatchClear("your system's tag")) - { - var patch = new SystemPatch {Tag = null}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, patch)); - - await ctx.Reply($"{Emojis.Success} System tag cleared."); - } else if (!ctx.HasNext(skipFlags: false)) - { - if (ctx.System.Tag == null) - await ctx.Reply($"You currently have no system tag. To set one, type `pk;s tag `."); - else - await ctx.Reply($"Your current system tag is {ctx.System.Tag.AsCode()}. To change it, type `pk;s tag `. To clear it, type `pk;s tag -clear`."); - } + if (target.Description == null) + await ctx.Reply(noDescriptionSetMessage); else - { - var newTag = ctx.RemainderOrNull(skipFlags: false); - if (newTag != null) - if (newTag.Length > Limits.MaxSystemTagLength) - throw Errors.SystemNameTooLongError(newTag.Length); - - var patch = new SystemPatch {Tag = newTag}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, patch)); - - await ctx.Reply($"{Emojis.Success} System tag changed. Member names will now end with {newTag.AsCode()} when proxied."); - } + await ctx.Reply($"```\n{target.Description}\n```"); + return; } - - public async Task Avatar(Context ctx) + + if (!ctx.HasNext(false)) { - ctx.CheckSystem(); - - async Task ClearIcon() - { - await _db.Execute(c => _repo.UpdateSystem(c, ctx.System.Id, new SystemPatch {AvatarUrl = null})); - await ctx.Reply($"{Emojis.Success} System icon cleared."); - } - - async Task SetIcon(ParsedImage img) - { - await AvatarUtils.VerifyAvatarOrThrow(img.Url); - - await _db.Execute(c => _repo.UpdateSystem(c, ctx.System.Id, new SystemPatch {AvatarUrl = img.Url})); - - var msg = img.Source switch - { - AvatarSource.User => $"{Emojis.Success} System icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon will need to be re-set.", - AvatarSource.Url => $"{Emojis.Success} System icon changed to the image at the given URL.", - AvatarSource.Attachment => $"{Emojis.Success} System icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the system icon will stop working.", - _ => throw new ArgumentOutOfRangeException() - }; - - // The attachment's already right there, no need to preview it. - var hasEmbed = img.Source != AvatarSource.Attachment; - await (hasEmbed - ? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(img.Url).Build()) - : ctx.Reply(msg)); - } - - async Task ShowIcon() - { - if ((ctx.System.AvatarUrl?.Trim() ?? "").Length > 0) - { - var eb = new DiscordEmbedBuilder() - .WithTitle("System icon") - .WithImageUrl(ctx.System.AvatarUrl) - .WithDescription("To clear, use `pk;system icon clear`."); - await ctx.Reply(embed: eb.Build()); - } - else - throw new PKSyntaxError("This system does not have an icon set. Set one by attaching an image to this command, or by passing an image URL or @mention."); - } - - if (await ctx.MatchClear("your system's icon")) - await ClearIcon(); - else if (await ctx.MatchImage() is {} img) - await SetIcon(img); + if (target.Description == null) + await ctx.Reply(noDescriptionSetMessage); else - await ShowIcon(); + await ctx.Reply(embed: new EmbedBuilder() + .Title("System description") + .Description(target.Description) + .Footer(new Embed.EmbedFooter( + "To print the description with formatting, type `pk;s description -raw`." + + (isOwnSystem ? "To clear it, type `pk;s description -clear`. To change it, type `pk;s description `." : ""))) + .Build()); + return; } - - public async Task Delete(Context ctx) { - ctx.CheckSystem(); - await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{ctx.System.Hid}`).\n**Note: this action is permanent.**"); - if (!await ctx.ConfirmWithReply(ctx.System.Hid)) - throw new PKError($"System deletion cancelled. Note that you must reply with your system ID (`{ctx.System.Hid}`) *verbatim*."); + ctx.CheckSystem().CheckOwnSystem(target); - await _db.Execute(conn => _repo.DeleteSystem(conn, ctx.System.Id)); - - await ctx.Reply($"{Emojis.Success} System deleted."); - } - - public async Task SystemProxy(Context ctx) + if (await ctx.MatchClear("your system's description")) { - ctx.CheckSystem().CheckGuildContext(); - var gs = await _db.Execute(c => _repo.GetSystemGuild(c, ctx.Guild.Id, ctx.System.Id)); + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Description = null }); - bool newValue; - if (ctx.Match("on", "enabled", "true", "yes")) newValue = true; - else if (ctx.Match("off", "disabled", "false", "no")) newValue = false; - else if (ctx.HasNext()) throw new PKSyntaxError("You must pass either \"on\" or \"off\"."); - else - { - if (gs.ProxyEnabled) - await ctx.Reply("Proxying in this server is currently **enabled** for your system. To disable it, type `pk;system proxy off`."); - else - await ctx.Reply("Proxying in this server is currently **disabled** for your system. To enable it, type `pk;system proxy on`."); - return; - } - - var patch = new SystemGuildPatch {ProxyEnabled = newValue}; - await _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.Guild.Id, patch)); - - if (newValue) - await ctx.Reply($"Message proxying in this server ({ctx.Guild.Name.EscapeMarkdown()}) is now **enabled** for your system."); - else - await ctx.Reply($"Message proxying in this server ({ctx.Guild.Name.EscapeMarkdown()}) is now **disabled** for your system."); + await ctx.Reply($"{Emojis.Success} System description cleared."); } - - public async Task SystemTimezone(Context ctx) + else { - if (ctx.System == null) throw Errors.NoSystemError; + var newDescription = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + if (newDescription.Length > Limits.MaxDescriptionLength) + throw Errors.StringTooLongError("Description", newDescription.Length, Limits.MaxDescriptionLength); - if (await ctx.MatchClear()) - { - var clearPatch = new SystemPatch {UiTz = "UTC"}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, clearPatch)); + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Description = newDescription }); - await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC)."); - return; - } - - var zoneStr = ctx.RemainderOrNull(); - if (zoneStr == null) - { - await ctx.Reply( - $"Your current system time zone is set to **{ctx.System.UiTz}**. It is currently **{SystemClock.Instance.GetCurrentInstant().FormatZoned(ctx.System)}** in that time zone. To change your system time zone, type `pk;s tz `."); - return; - } - - var zone = await FindTimeZone(ctx, zoneStr); - if (zone == null) throw Errors.InvalidTimeZone(zoneStr); - - var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone); - var msg = $"This will change the system time zone to **{zone.Id}**. The current time is **{currentTime.FormatZoned()}**. Is this correct?"; - if (!await ctx.PromptYesNo(msg)) throw Errors.TimezoneChangeCancelled; - - var patch = new SystemPatch {UiTz = zone.Id}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, patch)); - - await ctx.Reply($"System time zone changed to **{zone.Id}**."); - } - - public async Task SystemPrivacy(Context ctx) - { - ctx.CheckSystem(); - - Task PrintEmbed() - { - var eb = new DiscordEmbedBuilder() - .WithTitle("Current privacy settings for your system") - .AddField("Description", ctx.System.DescriptionPrivacy.Explanation()) - .AddField("Member list", ctx.System.MemberListPrivacy.Explanation()) - .AddField("Group list", ctx.System.GroupListPrivacy.Explanation()) - .AddField("Current fronter(s)", ctx.System.FrontPrivacy.Explanation()) - .AddField("Front/switch history", ctx.System.FrontHistoryPrivacy.Explanation()) - .WithDescription("To edit privacy settings, use the command:\n`pk;system privacy `\n\n- `subject` is one of `description`, `list`, `front`, `fronthistory`, `groups`, or `all` \n- `level` is either `public` or `private`."); - return ctx.Reply(embed: eb.Build()); - } - - async Task SetLevel(SystemPrivacySubject subject, PrivacyLevel level) - { - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, new SystemPatch().WithPrivacy(subject, level))); - - var levelExplanation = level switch - { - PrivacyLevel.Public => "be able to query", - PrivacyLevel.Private => "*not* be able to query", - _ => "" - }; - - var subjectStr = subject switch - { - SystemPrivacySubject.Description => "description", - SystemPrivacySubject.Front => "front", - SystemPrivacySubject.FrontHistory => "front history", - SystemPrivacySubject.MemberList => "member list", - SystemPrivacySubject.GroupList => "group list", - _ => "" - }; - - var msg = $"System {subjectStr} privacy has been set to **{level.LevelName()}**. Other accounts will now {levelExplanation} your system {subjectStr}."; - await ctx.Reply($"{Emojis.Success} {msg}"); - } - - async Task SetAll(PrivacyLevel level) - { - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, new SystemPatch().WithAllPrivacy(level))); - - var msg = level switch - { - PrivacyLevel.Private => $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now not be able to view your member list, group list, front history, or system description.", - PrivacyLevel.Public => $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now be able to view everything.", - _ => "" - }; - - await ctx.Reply($"{Emojis.Success} {msg}"); - } - - if (!ctx.HasNext()) - await PrintEmbed(); - else if (ctx.Match("all")) - await SetAll(ctx.PopPrivacyLevel()); - else - await SetLevel(ctx.PopSystemPrivacySubject(), ctx.PopPrivacyLevel()); - } - - public async Task SystemPing(Context ctx) - { - ctx.CheckSystem(); - - if (!ctx.HasNext()) - { - if (ctx.System.PingsEnabled) {await ctx.Reply("Reaction pings are currently **enabled** for your system. To disable reaction pings, type `pk;s ping disable`.");} - else {await ctx.Reply("Reaction pings are currently **disabled** for your system. To enable reaction pings, type `pk;s ping enable`.");} - } - else { - if (ctx.Match("on", "enable")) { - var patch = new SystemPatch {PingsEnabled = true}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, patch)); - - await ctx.Reply("Reaction pings have now been enabled."); - } - if (ctx.Match("off", "disable")) { - var patch = new SystemPatch {PingsEnabled = false}; - await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, patch)); - - await ctx.Reply("Reaction pings have now been disabled."); - } - } - } - - public async Task FindTimeZone(Context ctx, string zoneStr) { - // First, if we're given a flag emoji, we extract the flag emoji code from it. - zoneStr = Core.StringUtils.ExtractCountryFlag(zoneStr) ?? zoneStr; - - // Then, we find all *locations* matching either the given country code or the country name. - var locations = TzdbDateTimeZoneSource.Default.Zone1970Locations; - var matchingLocations = locations.Where(l => l.Countries.Any(c => - string.Equals(c.Code, zoneStr, StringComparison.InvariantCultureIgnoreCase) || - string.Equals(c.Name, zoneStr, StringComparison.InvariantCultureIgnoreCase))); - - // Then, we find all (unique) time zone IDs that match. - var matchingZones = matchingLocations.Select(l => DateTimeZoneProviders.Tzdb.GetZoneOrNull(l.ZoneId)) - .Distinct().ToList(); - - // If the set of matching zones is empty (ie. we didn't find anything), we try a few other things. - if (matchingZones.Count == 0) - { - // First, we try to just find the time zone given directly and return that. - var givenZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(zoneStr); - if (givenZone != null) return givenZone; - - // If we didn't find anything there either, we try parsing the string as an offset, then - // find all possible zones that match that offset. For an offset like UTC+2, this doesn't *quite* - // work, since there are 57(!) matching zones (as of 2019-06-13) - but for less populated time zones - // this could work nicely. - var inputWithoutUtc = zoneStr.Replace("UTC", "").Replace("GMT", ""); - - var res = OffsetPattern.CreateWithInvariantCulture("+H").Parse(inputWithoutUtc); - if (!res.Success) res = OffsetPattern.CreateWithInvariantCulture("+H:mm").Parse(inputWithoutUtc); - - // If *this* didn't parse correctly, fuck it, bail. - if (!res.Success) return null; - var offset = res.Value; - - // To try to reduce the count, we go by locations from the 1970+ database instead of just the full database - // This elides regions that have been identical since 1970, omitting small distinctions due to Ancient History(tm). - var allZones = TzdbDateTimeZoneSource.Default.Zone1970Locations.Select(l => l.ZoneId).Distinct(); - matchingZones = allZones.Select(z => DateTimeZoneProviders.Tzdb.GetZoneOrNull(z)) - .Where(z => z.GetUtcOffset(SystemClock.Instance.GetCurrentInstant()) == offset).ToList(); - } - - // If we have a list of viable time zones, we ask the user which is correct. - - // If we only have one, return that one. - if (matchingZones.Count == 1) - return matchingZones.First(); - - // Otherwise, prompt and return! - return await ctx.Choose("There were multiple matches for your time zone query. Please select the region that matches you the closest:", matchingZones, - z => - { - if (TzdbDateTimeZoneSource.Default.Aliases.Contains(z.Id)) - return $"**{z.Id}**, {string.Join(", ", TzdbDateTimeZoneSource.Default.Aliases[z.Id])}"; - - return $"**{z.Id}**"; - }); + await ctx.Reply($"{Emojis.Success} System description changed."); } } -} + + public async Task Color(Context ctx, PKSystem target) + { + var isOwnSystem = ctx.System?.Id == target.Id; + + if (!isOwnSystem || !ctx.HasNext(false)) + { + if (target.Color == null) + await ctx.Reply( + "This system does not have a color set." + (isOwnSystem ? " To set one, type `pk;system color `." : "")); + else + await ctx.Reply(embed: new EmbedBuilder() + .Title("System color") + .Color(target.Color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20")) + .Description( + $"This system's color is **#{target.Color}**." + (isOwnSystem ? " To clear it, type `pk;s color -clear`." : "")) + .Build()); + return; + } + + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.MatchClear()) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Color = Partial.Null() }); + + await ctx.Reply($"{Emojis.Success} System color cleared."); + } + else + { + var color = ctx.RemainderOrNull(); + + if (color.StartsWith("#")) color = color.Substring(1); + if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); + + await ctx.Repository.UpdateSystem(target.Id, + new SystemPatch { Color = Partial.Present(color.ToLowerInvariant()) }); + + await ctx.Reply(embed: new EmbedBuilder() + .Title($"{Emojis.Success} System color changed.") + .Color(color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{color}/?text=%20")) + .Build()); + } + } + + public async Task Tag(Context ctx, PKSystem target) + { + var isOwnSystem = ctx.System?.Id == target.Id; + + var noTagSetMessage = isOwnSystem + ? "You currently have no system tag set. To set one, type `pk;s tag `." + : "This system currently has no system tag set."; + + if (ctx.MatchRaw()) + { + if (target.Tag == null) + await ctx.Reply(noTagSetMessage); + else + await ctx.Reply($"```\n{target.Tag}\n```"); + return; + } + + if (!ctx.HasNext(false)) + { + if (target.Tag == null) + await ctx.Reply(noTagSetMessage); + else + await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current system tag is {target.Tag.AsCode()}." + + (isOwnSystem ? "To change it, type `pk;s tag `. To clear it, type `pk;s tag -clear`." : "")); + return; + } + + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.MatchClear("your system's tag")) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Tag = null }); + + await ctx.Reply($"{Emojis.Success} System tag cleared."); + } + else + { + var newTag = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + if (newTag != null) + if (newTag.Length > Limits.MaxSystemTagLength) + throw Errors.StringTooLongError("System tag", newTag.Length, Limits.MaxSystemTagLength); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Tag = newTag }); + + await ctx.Reply( + $"{Emojis.Success} System tag changed. Member names will now end with {newTag.AsCode()} when proxied."); + } + } + + public async Task ServerTag(Context ctx, PKSystem target) + { + ctx.CheckSystem().CheckOwnSystem(target).CheckGuildContext(); + + var setDisabledWarning = + $"{Emojis.Warn} Your system tag is currently **disabled** in this server. No tag will be applied when proxying.\nTo re-enable the system tag in the current server, type `pk;s servertag -enable`."; + + var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); + + async Task Show(bool raw = false) + { + if (settings.Tag != null) + { + if (raw) + { + await ctx.Reply($"```{settings.Tag}```"); + return; + } + + var msg = $"Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}"; + if (!settings.TagEnabled) + msg += ", but it is currently **disabled**. To re-enable it, type `pk;s servertag -enable`."; + else + msg += + ". To change it, type `pk;s servertag `. To clear it, type `pk;s servertag -clear`."; + + await ctx.Reply(msg); + return; + } + + if (!settings.TagEnabled) + await ctx.Reply( + $"Your global system tag is {target.Tag}, but it is **disabled** in this server. To re-enable it, type `pk;s servertag -enable`"); + else + await ctx.Reply( + $"You currently have no system tag specific to the server '{ctx.Guild.Name}'. To set one, type `pk;s servertag `. To disable the system tag in the current server, type `pk;s servertag -disable`."); + } + + async Task Set() + { + var newTag = ctx.RemainderOrNull(false); + if (newTag != null && newTag.Length > Limits.MaxSystemTagLength) + throw Errors.StringTooLongError("System server tag", newTag.Length, Limits.MaxSystemTagLength); + + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { Tag = newTag }); + + await ctx.Reply( + $"{Emojis.Success} System server tag changed. Member names will now end with {newTag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'."); + + if (!ctx.MessageContext.TagEnabled) + await ctx.Reply(setDisabledWarning); + } + + async Task Clear() + { + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { Tag = null }); + + await ctx.Reply( + $"{Emojis.Success} System server tag cleared. Member names will now end with the global system tag, if there is one set."); + + if (!ctx.MessageContext.TagEnabled) + await ctx.Reply(setDisabledWarning); + } + + async Task EnableDisable(bool newValue) + { + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, + new SystemGuildPatch { TagEnabled = newValue }); + + await ctx.Reply(PrintEnableDisableResult(newValue, newValue != ctx.MessageContext.TagEnabled)); + } + + string PrintEnableDisableResult(bool newValue, bool changedValue) + { + var opStr = newValue ? "enabled" : "disabled"; + var str = ""; + + if (!changedValue) + str = $"{Emojis.Note} The system tag is already {opStr} in this server."; + else + str = $"{Emojis.Success} System tag {opStr} in this server."; + + if (newValue) + { + if (ctx.MessageContext.TagEnabled) + { + if (ctx.MessageContext.SystemGuildTag == null) + str += + " However, you do not have a system tag specific to this server. Messages will be proxied using your global system tag, if there is one set."; + else + str += + $" Your current system tag in '{ctx.Guild.Name}' is {ctx.MessageContext.SystemGuildTag.AsCode()}."; + } + else + { + if (ctx.MessageContext.SystemGuildTag != null) + str += + $" Member names will now end with the server-specific tag {ctx.MessageContext.SystemGuildTag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'."; + else + str += + " Member names will now end with the global system tag when proxied in the current server, if there is one set."; + } + } + + return str; + } + + if (await ctx.MatchClear("your system's server tag")) + await Clear(); + else if (ctx.Match("disable") || ctx.MatchFlag("disable")) + await EnableDisable(false); + else if (ctx.Match("enable") || ctx.MatchFlag("enable")) + await EnableDisable(true); + else if (ctx.MatchRaw()) + await Show(true); + else if (!ctx.HasNext(false)) + await Show(); + else + await Set(); + } + + public async Task Pronouns(Context ctx, PKSystem target) + { + ctx.CheckSystemPrivacy(target.Id, target.PronounPrivacy); + + var isOwnSystem = ctx.System.Id == target.Id; + + var noPronounsSetMessage = "This system does not have pronouns set."; + if (isOwnSystem) + noPronounsSetMessage += " To set some, type `pk;system pronouns `"; + + if (ctx.MatchRaw()) + { + if (target.Pronouns == null) + await ctx.Reply(noPronounsSetMessage); + else + await ctx.Reply($"```\n{target.Pronouns}\n```"); + return; + } + + if (!ctx.HasNext(false)) + { + if (target.Pronouns == null) + await ctx.Reply(noPronounsSetMessage); + else + await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `pk;system pronouns -raw`." + + (isOwnSystem ? " To clear them, type `pk;system pronouns -clear`." + : "")); + return; + } + + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.MatchClear("your system's pronouns")) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Pronouns = null }); + + await ctx.Reply($"{Emojis.Success} System pronouns cleared."); + } + else + { + var newPronouns = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + if (newPronouns != null) + if (newPronouns.Length > Limits.MaxPronounsLength) + throw Errors.StringTooLongError("Pronouns", newPronouns.Length, Limits.MaxPronounsLength); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Pronouns = newPronouns }); + + await ctx.Reply( + $"{Emojis.Success} System pronouns changed."); + } + } + + public async Task Avatar(Context ctx, PKSystem target) + { + async Task ClearIcon() + { + ctx.CheckOwnSystem(target); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = null }); + await ctx.Reply($"{Emojis.Success} System icon cleared."); + } + + async Task SetIcon(ParsedImage img) + { + ctx.CheckOwnSystem(target); + + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.Url }); + + var msg = img.Source switch + { + AvatarSource.User => + $"{Emojis.Success} System icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon will need to be re-set.", + AvatarSource.Url => $"{Emojis.Success} System icon changed to the image at the given URL.", + AvatarSource.Attachment => + $"{Emojis.Success} System icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the system icon will stop working.", + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + async Task ShowIcon() + { + if ((target.AvatarUrl?.Trim() ?? "").Length > 0) + { + var eb = new EmbedBuilder() + .Title("System icon") + .Image(new Embed.EmbedImage(target.AvatarUrl.TryGetCleanCdnUrl())); + if (target.Id == ctx.System?.Id) + eb.Description("To clear, use `pk;system icon clear`."); + await ctx.Reply(embed: eb.Build()); + } + else + { + throw new PKSyntaxError( + "This system does not have an icon set. Set one by attaching an image to this command, or by passing an image URL or @mention."); + } + } + + if (target != null && target?.Id != ctx.System?.Id) + { + await ShowIcon(); + return; + } + + if (await ctx.MatchClear("your system's icon")) + await ClearIcon(); + else if (await ctx.MatchImage() is { } img) + await SetIcon(img); + else + await ShowIcon(); + } + + public async Task BannerImage(Context ctx, PKSystem target) + { + ctx.CheckSystemPrivacy(target.Id, target.DescriptionPrivacy); + + var isOwnSystem = target.Id == ctx.System?.Id; + + if (!ctx.HasNext() && ctx.Message.Attachments.Length == 0) + { + if ((target.BannerImage?.Trim() ?? "").Length > 0) + { + var eb = new EmbedBuilder() + .Title("System banner image") + .Image(new Embed.EmbedImage(target.BannerImage)); + + if (isOwnSystem) + eb.Description("To clear, use `pk;system banner clear`."); + + await ctx.Reply(embed: eb.Build()); + } + else + { + throw new PKSyntaxError("This system does not have a banner image set." + + (isOwnSystem ? "Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); + } + + return; + } + + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.MatchClear("your system's banner image")) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = null }); + await ctx.Reply($"{Emojis.Success} System banner image cleared."); + } + + else if (await ctx.MatchImage() is { } img) + { + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.Url }); + + var msg = img.Source switch + { + AvatarSource.Url => $"{Emojis.Success} System banner image changed to the image at the given URL.", + AvatarSource.Attachment => + $"{Emojis.Success} System banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", + AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + } + + public async Task Delete(Context ctx, PKSystem target) + { + ctx.CheckSystem().CheckOwnSystem(target); + + await ctx.Reply( + $"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{target.Hid}`).\n" + +$"**Note: this action is permanent,** but you will get a copy of your system's data that can be re-imported into PluralKit at a later date sent to you in DMs." + +" If you don't want this to happen, use `pk;s delete -no-export` instead."); + if (!await ctx.ConfirmWithReply(target.Hid)) + throw new PKError( + $"System deletion cancelled. Note that you must reply with your system ID (`{target.Hid}`) *verbatim*."); + + // If the user confirms the deletion, export their system and send them the export file before actually + // deleting their system, unless they specifically tell us not to do an export. + + var noExport = ctx.MatchFlag("ne", "no-export"); + if (!noExport) + { + var json = await ctx.BusyIndicator(async () => + { + // Make the actual data file + var data = await _dataFiles.ExportSystem(ctx.System); + return JsonConvert.SerializeObject(data, Formatting.None); + }); + + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + try + { + var dm = await _dmCache.GetOrCreateDmChannel(ctx.Author.Id); + var msg = await ctx.Rest.CreateMessage(dm, + new MessageRequest { Content = $"{Emojis.Success} System deleted. If you want to set up your PluralKit system again, you can import the file below with `pk;import`." }, + new[] { new MultipartFile("system.json", stream, null) }); + await ctx.Rest.CreateMessage(dm, new MessageRequest { Content = $"<{msg.Attachments[0].Url}>" }); + + // If the original message wasn't posted in DMs, send a public reminder + if (ctx.Channel.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Success} System deleted. Check your DMs for a copy of your system's data!"); + } + catch (ForbiddenException) + { + // If user has DMs closed, tell 'em to open them + throw new PKError( + $"I couldn't send you a DM with your system's data before deleting your system. Either make sure your DMs are open, or use `pk;s delete -no-export` to delete your system without exporting first."); + } + } + else + { + await ctx.Reply($"{Emojis.Success} System deleted."); + } + + // Now that we've sent the export data (or been told not to), we can safely delete the system + + await ctx.Repository.DeleteSystem(target.Id); + } + + public async Task SystemProxy(Context ctx) + { + ctx.CheckSystem(); + + var guild = await ctx.MatchGuild() ?? ctx.Guild ?? + throw new PKError("You must run this command in a server or pass a server ID."); + + var gs = await ctx.Repository.GetSystemGuild(guild.Id, ctx.System.Id); + + string serverText; + if (guild.Id == ctx.Guild?.Id) + serverText = $"this server ({guild.Name.EscapeMarkdown()})"; + else + serverText = $"the server {guild.Name.EscapeMarkdown()}"; + + if (!ctx.HasNext()) + { + if (gs.ProxyEnabled) + await ctx.Reply( + $"Proxying in {serverText} is currently **enabled** for your system. To disable it, type `pk;system proxy off`."); + else + await ctx.Reply( + $"Proxying in {serverText} is currently **disabled** for your system. To enable it, type `pk;system proxy on`."); + return; + } + + var newValue = ctx.MatchToggle(); + + await ctx.Repository.UpdateSystemGuild(ctx.System.Id, guild.Id, new SystemGuildPatch { ProxyEnabled = newValue }); + + if (newValue) + await ctx.Reply($"Message proxying in {serverText} is now **enabled** for your system."); + else + await ctx.Reply($"Message proxying in {serverText} is now **disabled** for your system."); + } + + public async Task SystemPrivacy(Context ctx, PKSystem target) + { + ctx.CheckSystem().CheckOwnSystem(target); + + Task PrintEmbed() + { + var eb = new EmbedBuilder() + .Title("Current privacy settings for your system") + .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) + .Field(new Embed.Field("Pronouns", target.PronounPrivacy.Explanation())) + .Field(new Embed.Field("Member list", target.MemberListPrivacy.Explanation())) + .Field(new Embed.Field("Group list", target.GroupListPrivacy.Explanation())) + .Field(new Embed.Field("Current fronter(s)", target.FrontPrivacy.Explanation())) + .Field(new Embed.Field("Front/switch history", target.FrontHistoryPrivacy.Explanation())) + .Description( + "To edit privacy settings, use the command:\n`pk;system privacy `\n\n- `subject` is one of `description`, `list`, `front`, `fronthistory`, `groups`, or `all` \n- `level` is either `public` or `private`."); + return ctx.Reply(embed: eb.Build()); + } + + async Task SetLevel(SystemPrivacySubject subject, PrivacyLevel level) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch().WithPrivacy(subject, level)); + + var levelExplanation = level switch + { + PrivacyLevel.Public => "be able to query", + PrivacyLevel.Private => "*not* be able to query", + _ => "" + }; + + var subjectStr = subject switch + { + SystemPrivacySubject.Description => "description", + SystemPrivacySubject.Pronouns => "pronouns", + SystemPrivacySubject.Front => "front", + SystemPrivacySubject.FrontHistory => "front history", + SystemPrivacySubject.MemberList => "member list", + SystemPrivacySubject.GroupList => "group list", + _ => "" + }; + + var msg = + $"System {subjectStr} privacy has been set to **{level.LevelName()}**. Other accounts will now {levelExplanation} your system {subjectStr}."; + await ctx.Reply($"{Emojis.Success} {msg}"); + } + + async Task SetAll(PrivacyLevel level) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch().WithAllPrivacy(level)); + + var msg = level switch + { + PrivacyLevel.Private => + $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now not be able to view your member list, group list, front history, or system description.", + PrivacyLevel.Public => + $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now be able to view everything.", + _ => "" + }; + + await ctx.Reply($"{Emojis.Success} {msg}"); + } + + if (!ctx.HasNext()) + await PrintEmbed(); + else if (ctx.Match("all")) + await SetAll(ctx.PopPrivacyLevel()); + else + await SetLevel(ctx.PopSystemPrivacySubject(), ctx.PopPrivacyLevel()); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 9dfc15da..2ed9a841 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -1,132 +1,153 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System.Text; using NodaTime; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class SystemFront { - public class SystemFront + private readonly EmbedService _embeds; + + public SystemFront(EmbedService embeds) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; + _embeds = embeds; + } - public SystemFront(EmbedService embeds, IDatabase db, ModelRepository repo) - { - _embeds = embeds; - _db = db; - _repo = repo; - } - - struct FrontHistoryEntry - { - public readonly Instant? LastTime; - public readonly PKSwitch ThisSwitch; + public async Task SystemFronter(Context ctx, PKSystem system) + { + if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system.Id, system.FrontPrivacy); - public FrontHistoryEntry(Instant? lastTime, PKSwitch thisSwitch) + var sw = await ctx.Repository.GetLatestSwitch(system.Id); + if (sw == null) throw Errors.NoRegisteredSwitches; + + await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, ctx.Zone, ctx.LookupContextFor(system.Id))); + } + + public async Task SystemFrontHistory(Context ctx, PKSystem system) + { + if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system.Id, system.FrontHistoryPrivacy); + + var totalSwitches = await ctx.Repository.GetSwitchCount(system.Id); + if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; + + var sws = ctx.Repository.GetSwitches(system.Id) + .Scan(new FrontHistoryEntry(null, null), + (lastEntry, newSwitch) => new FrontHistoryEntry(lastEntry.ThisSwitch?.Timestamp, newSwitch)); + + var embedTitle = system.Name != null + ? $"Front history of {system.Name} (`{system.Hid}`)" + : $"Front history of `{system.Hid}`"; + + await ctx.Paginate( + sws, + totalSwitches, + 10, + embedTitle, + system.Color, + async (builder, switches) => { - LastTime = lastTime; - ThisSwitch = thisSwitch; - } - } - - public async Task SystemFronter(Context ctx, PKSystem system) - { - if (system == null) throw Errors.NoSystemError; - ctx.CheckSystemPrivacy(system, system.FrontPrivacy); - - await using var conn = await _db.Obtain(); - - var sw = await _repo.GetLatestSwitch(conn, system.Id); - if (sw == null) throw Errors.NoRegisteredSwitches; - - await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, system.Zone, ctx.LookupContextFor(system))); - } - - public async Task SystemFrontHistory(Context ctx, PKSystem system) - { - if (system == null) throw Errors.NoSystemError; - ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy); - - // Gotta be careful here: if we dispose of the connection while the IAE is alive, boom - await using var conn = await _db.Obtain(); - - var totalSwitches = await _repo.GetSwitchCount(conn, system.Id); - if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; - - var sws = _repo.GetSwitches(conn, system.Id) - .Scan(new FrontHistoryEntry(null, null), - (lastEntry, newSwitch) => new FrontHistoryEntry(lastEntry.ThisSwitch?.Timestamp, newSwitch)); - - var embedTitle = system.Name != null ? $"Front history of {system.Name} (`{system.Hid}`)" : $"Front history of `{system.Hid}`"; - - await ctx.Paginate( - sws, - totalSwitches, - 10, - embedTitle, - async (builder, switches) => + var sb = new StringBuilder(); + foreach (var entry in switches) { - foreach (var entry in switches) + var lastSw = entry.LastTime; + + var sw = entry.ThisSwitch; + + // Fetch member list and format + + var members = await ctx.Database.Execute(c => ctx.Repository.GetSwitchMembers(c, sw.Id)).ToListAsync(); + var membersStr = members.Any() + ? string.Join(", ", members.Select(m => m.NameFor(ctx))) + : "no fronter"; + + var switchSince = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; + + // If this isn't the latest switch, we also show duration + string stringToAdd; + if (lastSw != null) { - var lastSw = entry.LastTime; - - var sw = entry.ThisSwitch; - - // Fetch member list and format - await using var conn = await _db.Obtain(); - - var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id)).ToListAsync(); - var membersStr = members.Any() ? string.Join(", ", members.Select(m => m.NameFor(ctx))) : "no fronter"; - - var switchSince = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; - - // If this isn't the latest switch, we also show duration - string stringToAdd; - if (lastSw != null) - { - // Calculate the time between the last switch (that we iterated - ie. the next one on the timeline) and the current one - var switchDuration = lastSw.Value - sw.Timestamp; - stringToAdd = - $"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago, for {switchDuration.FormatDuration()})\n"; - } - else - { - stringToAdd = - $"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago)\n"; - } - try // Unfortunately the only way to test DiscordEmbedBuilder.Description max length is this - { - builder.Description += stringToAdd; - } - catch (ArgumentException) - { - break; - }// TODO: Make sure this works + // Calculate the time between the last switch (that we iterated - ie. the next one on the timeline) and the current one + var switchDuration = lastSw.Value - sw.Timestamp; + stringToAdd = + $"**{membersStr}** ({sw.Timestamp.FormatZoned(ctx.Zone)}, {switchSince.FormatDuration()} ago, for {switchDuration.FormatDuration()})\n"; } + else + { + stringToAdd = + $"**{membersStr}** ({sw.Timestamp.FormatZoned(ctx.Zone)}, {switchSince.FormatDuration()} ago)\n"; + } + + if (sb.Length + stringToAdd.Length >= 4096) + break; + sb.Append(stringToAdd); } - ); - } - - public async Task SystemFrontPercent(Context ctx, PKSystem system) + + builder.Description(sb.ToString()); + } + ); + } + + public async Task FrontPercent(Context ctx, PKSystem? system = null, PKGroup? group = null) + { + if (system == null && group == null) throw Errors.NoSystemError; + if (system == null) system = await GetGroupSystem(ctx, group); + + ctx.CheckSystemPrivacy(system.Id, system.FrontHistoryPrivacy); + + var totalSwitches = await ctx.Repository.GetSwitchCount(system.Id); + if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; + + var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only"); + var showFlat = ctx.MatchFlag("flat"); + + var durationStr = ctx.RemainderOrNull() ?? "30d"; + + // Picked the UNIX epoch as a random date + // even though we don't store switch timestamps in UNIX time + // I assume most people won't have switches logged previously to that (?) + if (durationStr == "full") + durationStr = "1970-01-01"; + + var now = SystemClock.Instance.GetCurrentInstant(); + + var rangeStart = DateUtils.ParseDateTime(durationStr, true, ctx.Zone); + if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); + if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; + + var title = new StringBuilder("Frontpercent of "); + if (group != null) + title.Append($"{group.NameFor(ctx)} (`{group.Hid}`)"); + else if (system.Name != null) + title.Append($"{system.Name} (`{system.Hid}`)"); + else + title.Append($"`{system.Hid}`"); + + var frontpercent = await ctx.Database.Execute(c => ctx.Repository.GetFrontBreakdown(c, system.Id, group?.Id, rangeStart.Value.ToInstant(), now)); + await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system, group, ctx.Zone, + ctx.LookupContextFor(system.Id), title.ToString(), ignoreNoFronters, showFlat)); + } + + private async Task GetGroupSystem(Context ctx, PKGroup target) + { + var system = ctx.System; + if (system?.Id == target.System) + return system; + return await ctx.Repository.GetSystem(target.System)!; + } + + private struct FrontHistoryEntry + { + public readonly Instant? LastTime; + public readonly PKSwitch ThisSwitch; + + public FrontHistoryEntry(Instant? lastTime, PKSwitch thisSwitch) { - if (system == null) throw Errors.NoSystemError; - ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy); - - string durationStr = ctx.RemainderOrNull() ?? "30d"; - - var now = SystemClock.Instance.GetCurrentInstant(); - - var rangeStart = DateUtils.ParseDateTime(durationStr, true, system.Zone); - if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); - if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; - - var frontpercent = await _db.Execute(c => _repo.GetFrontBreakdown(c, system.Id, rangeStart.Value.ToInstant(), now)); - await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system.Zone, ctx.LookupContextFor(system))); + LastTime = lastTime; + ThisSwitch = thisSwitch; } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemLink.cs b/PluralKit.Bot/Commands/SystemLink.cs index 70c829dd..4fe256d0 100644 --- a/PluralKit.Bot/Commands/SystemLink.cs +++ b/PluralKit.Bot/Commands/SystemLink.cs @@ -1,66 +1,47 @@ -using System.Linq; -using System.Threading.Tasks; - -using DSharpPlus.Entities; +using Myriad.Extensions; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class SystemLink { - public class SystemLink + public async Task LinkSystem(Context ctx) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + ctx.CheckSystem(); - public SystemLink(IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - } - - public async Task LinkSystem(Context ctx) - { - ctx.CheckSystem(); + var account = await ctx.MatchUser() ?? + throw new PKSyntaxError("You must pass an account to link with (either ID or @mention)."); + var accountIds = await ctx.Repository.GetSystemAccounts(ctx.System.Id); + if (accountIds.Contains(account.Id)) + throw Errors.AccountAlreadyLinked; - await using var conn = await _db.Obtain(); - - var account = await ctx.MatchUser() ?? throw new PKSyntaxError("You must pass an account to link with (either ID or @mention)."); - var accountIds = await _repo.GetSystemAccounts(conn, ctx.System.Id); - if (accountIds.Contains(account.Id)) - throw Errors.AccountAlreadyLinked; + var existingAccount = await ctx.Repository.GetSystemByAccount(account.Id); + if (existingAccount != null) + throw Errors.AccountInOtherSystem(existingAccount); - var existingAccount = await _repo.GetSystemByAccount(conn, account.Id); - if (existingAccount != null) - throw Errors.AccountInOtherSystem(existingAccount); + var msg = $"{account.Mention()}, please confirm the link."; + if (!await ctx.PromptYesNo(msg, "Confirm", account, false)) throw Errors.MemberLinkCancelled; + await ctx.Repository.AddAccount(ctx.System.Id, account.Id); + await ctx.Reply($"{Emojis.Success} Account linked to system."); + } - var msg = $"{account.Mention}, please confirm the link by clicking the {Emojis.Success} reaction on this message."; - var mentions = new IMention[] { new UserMention(account) }; - if (!await ctx.PromptYesNo(msg, user: account, mentions: mentions, matchFlag: false)) throw Errors.MemberLinkCancelled; - await _repo.AddAccount(conn, ctx.System.Id, account.Id); - await ctx.Reply($"{Emojis.Success} Account linked to system."); - } + public async Task UnlinkAccount(Context ctx) + { + ctx.CheckSystem(); - public async Task UnlinkAccount(Context ctx) - { - ctx.CheckSystem(); - - await using var conn = await _db.Obtain(); + ulong id; + if (!ctx.MatchUserRaw(out id)) + throw new PKSyntaxError("You must pass an account to link with (either ID or @mention)."); - ulong id; - if (!ctx.HasNext()) - id = ctx.Author.Id; - else if (!ctx.MatchUserRaw(out id)) - throw new PKSyntaxError("You must pass an account to link with (either ID or @mention)."); + var accountIds = (await ctx.Repository.GetSystemAccounts(ctx.System.Id)).ToList(); + if (!accountIds.Contains(id)) throw Errors.AccountNotLinked; + if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount; - var accountIds = (await _repo.GetSystemAccounts(conn, ctx.System.Id)).ToList(); - if (!accountIds.Contains(id)) throw Errors.AccountNotLinked; - if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount; - - var msg = $"Are you sure you want to unlink <@{id}> from your system?"; - if (!await ctx.PromptYesNo(msg)) throw Errors.MemberUnlinkCancelled; + var msg = $"Are you sure you want to unlink <@{id}> from your system?"; + if (!await ctx.PromptYesNo(msg, "Unlink")) throw Errors.MemberUnlinkCancelled; - await _repo.RemoveAccount(conn, ctx.System.Id, id); - await ctx.Reply($"{Emojis.Success} Account unlinked."); - } + await ctx.Repository.RemoveAccount(ctx.System.Id, id); + await ctx.Reply($"{Emojis.Success} Account unlinked."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index 7d7db63d..ba68fe26 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -1,43 +1,44 @@ using System.Text; -using System.Threading.Tasks; -using NodaTime; +using Humanizer; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class SystemList { - public class SystemList + public async Task MemberList(Context ctx, PKSystem target) { - private readonly IDatabase _db; - - public SystemList(IDatabase db) - { - _db = db; - } + if (target == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(target.Id, target.MemberListPrivacy); - public async Task MemberList(Context ctx, PKSystem target) - { - if (target == null) throw Errors.NoSystemError; - ctx.CheckSystemPrivacy(target, target.MemberListPrivacy); + // explanation of privacy lookup here: + // - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list) + // - RenderMemberList checks the indivual privacy for each member (NameFor, etc) + // the own system is always allowed to look up their list + var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.Id)); + await ctx.RenderMemberList( + ctx.LookupContextFor(target.Id), + target.Id, + GetEmbedTitle(target, opts), + target.Color, + opts + ); + } - var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target)); - await ctx.RenderMemberList(ctx.LookupContextFor(target), _db, target.Id, GetEmbedTitle(target, opts), opts); - } + private string GetEmbedTitle(PKSystem target, ListOptions opts) + { + var title = new StringBuilder("Members of "); - private string GetEmbedTitle(PKSystem target, MemberListOptions opts) - { - var title = new StringBuilder("Members of "); - - if (target.Name != null) - title.Append($"{target.Name} (`{target.Hid}`)"); - else - title.Append($"`{target.Hid}`"); - - if (opts.Search != null) - title.Append($" matching **{opts.Search}**"); - - return title.ToString(); - } + if (target.Name != null) + title.Append($"{target.Name} (`{target.Hid}`)"); + else + title.Append($"`{target.Hid}`"); + + if (opts.Search != null) + title.Append($" matching **{opts.Search.Truncate(100)}**"); + + return title.ToString(); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Token.cs b/PluralKit.Bot/Commands/Token.cs deleted file mode 100644 index e6a26cb9..00000000 --- a/PluralKit.Bot/Commands/Token.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Threading.Tasks; - -using DSharpPlus.Entities; -using DSharpPlus.Exceptions; - -using PluralKit.Core; - -namespace PluralKit.Bot -{ - public class Token - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - public Token(IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - } - - public async Task GetToken(Context ctx) - { - ctx.CheckSystem(); - - // Get or make a token - var token = ctx.System.Token ?? await MakeAndSetNewToken(ctx.System); - - try - { - // DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile) - var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); - await dm.SendMessageFixedAsync( - $"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:"); - await dm.SendMessageFixedAsync(token); - - // If we're not already in a DM, reply with a reminder to check - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Success} Check your DMs!"); - } - catch (UnauthorizedException) - { - // Can't check for permission errors beforehand, so have to handle here :/ - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); - } - } - - private async Task MakeAndSetNewToken(PKSystem system) - { - var patch = new SystemPatch {Token = StringUtils.GenerateToken()}; - system = await _db.Execute(conn => _repo.UpdateSystem(conn, system.Id, patch)); - return system.Token; - } - - public async Task RefreshToken(Context ctx) - { - ctx.CheckSystem(); - - if (ctx.System.Token == null) - { - // If we don't have a token, call the other method instead - // This does pretty much the same thing, except words the messages more appropriately for that :) - await GetToken(ctx); - return; - } - - try { - // DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile) - var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); - await dm.SendMessageFixedAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:"); - - // Make the new token after sending the first DM; this ensures if we can't DM, we also don't end up - // breaking their existing token as a side effect :) - var token = await MakeAndSetNewToken(ctx.System); - await dm.SendMessageFixedAsync(token); - - // If we're not already in a DM, reply with a reminder to check - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Success} Check your DMs!"); - } - catch (UnauthorizedException) - { - // Can't check for permission errors beforehand, so have to handle here :/ - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); - } - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 4ad5a2d0..ee46cb49 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -1,119 +1,186 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; + using Humanizer; + using NodaTime; + using PluralKit.Core; -namespace PluralKit.Bot { - /// - /// An exception class representing user-facing errors caused when parsing and executing commands. - /// - public class PKError : Exception +namespace PluralKit.Bot; + +/// +/// An exception class representing user-facing errors caused when parsing and executing commands. +/// +public class PKError: Exception +{ + public PKError(string message) : base(message) { } +} + +/// +/// A subclass of that represent command syntax errors, meaning they'll have their command +/// usages printed in the message. +/// +public class PKSyntaxError: PKError +{ + public PKSyntaxError(string message) : base(message) { } +} + +public static class Errors +{ + // TODO: is returning constructed errors and throwing them at call site a good idea, or should these be methods that insta-throw instead? + // or should we just like... go back to inlining them? at least for the one-time-use commands + + public static PKError NotOwnSystemError => new("You can only run this command on your own system."); + public static PKError NotOwnMemberError => new("You can only run this command on your own member."); + public static PKError NotOwnGroupError => new("You can only run this command on your own group."); + + public static PKError NotOwnInfo => new("You cannot look up private information of another system."); + + public static PKError NoSystemError => + new("You do not have a system registered with PluralKit. To create one, type `pk;system new`."); + + public static PKError ExistingSystemError => new( + "You already have a system registered with PluralKit. To view it, type `pk;system`. If you'd like to delete your system and start anew, type `pk;system delete`, or if you'd like to unlink this account from it, type `pk;unlink`."); + + public static PKError MissingMemberError => + new PKSyntaxError("You need to specify a member to run this command on."); + + public static PKError ProxyMustHaveText => + new PKSyntaxError("Example proxy message must contain the string 'text'."); + + public static PKError ProxyMultipleText => + new PKSyntaxError("Example proxy message must contain the string 'text' exactly once."); + + public static PKError MemberDeleteCancelled => new($"Member deletion cancelled. Stay safe! {Emojis.ThumbsUp}"); + + public static PKError AvatarInvalid => + new("Could not read image file - perhaps it's corrupted or the wrong format. Try a different image."); + + public static PKError UserHasNoAvatar => new("The given user has no avatar set."); + + public static PKError AccountAlreadyLinked => new("That account is already linked to your system."); + public static PKError AccountNotLinked => new("That account isn't linked to your system."); + + public static PKError UnlinkingLastAccount => new( + "Since this is the only account linked to this system, you cannot unlink it (as that would leave your system account-less). If you would like to delete your system, use `pk;system delete`."); + + public static PKError MemberLinkCancelled => new("Member link cancelled."); + public static PKError MemberUnlinkCancelled => new("Member unlink cancelled."); + + public static PKError DuplicateSwitchMembers => new("Duplicate members in member list."); + public static PKError SwitchMemberNotInSystem => new("One or more switch members aren't in your own system."); + public static PKError SwitchTimeInFuture => new("Can't move switch to a time in the future."); + public static PKError NoRegisteredSwitches => new("There are no registered switches for this system."); + public static PKError SwitchMoveCancelled => new("Switch move cancelled."); + public static PKError SwitchEditCancelled => new("Switch edit cancelled."); + public static PKError SwitchDeleteCancelled => new("Switch deletion cancelled."); + public static PKError TimezoneChangeCancelled => new("Time zone change cancelled."); + + public static PKError NoImportFilePassed => + new( + "You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command."); + + public static PKError InvalidImportFile => + new( + "Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); + + public static PKError ImportCancelled => new("Import cancelled."); + + public static PKError FrontPercentTimeInFuture => + new("Cannot get the front percent between now and a time in the future."); + + public static PKError LookupNotAllowed => new("You do not have permission to access this information."); + + public static PKError StringTooLongError(string name, int length, int maxLength) => + new($"{name} too long ({length}/{maxLength} characters)."); + + public static PKError MemberLimitReachedError(int limit) => new( + $"System has reached the maximum number of members ({limit}). Please delete unused members first in order to create new ones."); + + public static PKError InvalidColorError(string color) => + new($"\"{color}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000)."); + + public static PKError BirthdayParseError(string birthday) => new( + $"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); + + public static PKError AvatarServerError(HttpStatusCode statusCode) => new( + $"Server responded with status code {(int)statusCode}, are you sure your link is working?"); + + public static PKError AvatarFileSizeLimit(long size) => new( + $"File size too large ({size.Bytes().ToString("#.#")} > {Limits.AvatarFileSizeLimit.Bytes().ToString("#.#")}), try shrinking or compressing the image."); + + public static PKError AvatarNotAnImage(string mimeType) => new( + $"The given link does not point to an image{(mimeType != null ? $" ({mimeType})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif)."); + + public static PKError AvatarDimensionsTooLarge(int width, int height) => new( + $"Image too large ({width}x{height} > {Limits.AvatarDimensionLimit}x{Limits.AvatarDimensionLimit}), try resizing the image."); + + public static PKError InvalidUrl(string url) => new("The given URL is invalid."); + + public static PKError UrlTooLong(string url) => + new($"The given URL is too long ({url.Length}/{Limits.MaxUriLength} characters)."); + + public static PKError AccountInOtherSystem(PKSystem system) => + new($"The mentioned account is already linked to another system (see `pk;system {system.Hid}`)."); + + public static PKError SameSwitch(ICollection members, LookupContext ctx) { - public PKError(string message) : base(message) - { - } + if (members.Count == 0) return new PKError("There's already no one in front."); + if (members.Count == 1) return new PKError($"Member {members.First().NameFor(ctx)} is already fronting."); + return new PKError( + $"Members {string.Join(", ", members.Select(m => m.NameFor(ctx)))} are already fronting."); } - /// - /// A subclass of that represent command syntax errors, meaning they'll have their command - /// usages printed in the message. - /// - public class PKSyntaxError : PKError - { - public PKSyntaxError(string message) : base(message) - { - } - } - - public static class Errors { - // TODO: is returning constructed errors and throwing them at call site a good idea, or should these be methods that insta-throw instead? - // or should we just like... go back to inlining them? at least for the one-time-use commands + public static PKError InvalidDateTime(string str) => new( + $"Could not parse '{str}' as a valid date/time. Try using a syntax such as \"May 21, 12:30 PM\" or \"3d12h\" (ie. 3 days, 12 hours ago)."); - public static PKError NotOwnSystemError => new PKError($"You can only run this command on your own system."); - public static PKError NotOwnMemberError => new PKError($"You can only run this command on your own member."); - public static PKError NotOwnGroupError => new PKError($"You can only run this command on your own group."); - public static PKError NoSystemError => new PKError("You do not have a system registered with PluralKit. To create one, type `pk;system new`."); - public static PKError ExistingSystemError => new PKError("You already have a system registered with PluralKit. To view it, type `pk;system`. If you'd like to delete your system and start anew, type `pk;system delete`, or if you'd like to unlink this account from it, type `pk;unlink`."); - public static PKError MissingMemberError => new PKSyntaxError("You need to specify a member to run this command on."); + public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new( + $"Can't move switch to before last switch time ({time.FormatZoned()}), as it would cause conflicts."); - public static PKError SystemNameTooLongError(int length) => new PKError($"System name too long ({length}/{Limits.MaxSystemNameLength} characters)."); - public static PKError SystemTagTooLongError(int length) => new PKError($"System tag too long ({length}/{Limits.MaxSystemTagLength} characters)."); - public static PKError DescriptionTooLongError(int length) => new PKError($"Description too long ({length}/{Limits.MaxDescriptionLength} characters)."); - public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters)."); - public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters)."); - public static PKError MemberLimitReachedError(int limit) => new PKError($"System has reached the maximum number of members ({limit}). Please delete unused members first in order to create new ones."); + public static PKError TimezoneParseError(string timezone) => new( + $"Could not parse timezone offset {timezone}. Offset must be a value like 'UTC+5' or 'GMT-4:30'."); - public static PKError InvalidColorError(string color) => new PKError($"\"{color}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000)."); - public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); - public static PKError ProxyMustHaveText => new PKSyntaxError("Example proxy message must contain the string 'text'."); - public static PKError ProxyMultipleText => new PKSyntaxError("Example proxy message must contain the string 'text' exactly once."); - - public static PKError MemberDeleteCancelled => new PKError($"Member deletion cancelled. Stay safe! {Emojis.ThumbsUp}"); - public static PKError AvatarServerError(HttpStatusCode statusCode) => new PKError($"Server responded with status code {(int) statusCode}, are you sure your link is working?"); - public static PKError AvatarFileSizeLimit(long size) => new PKError($"File size too large ({size.Bytes().ToString("#.#")} > {Limits.AvatarFileSizeLimit.Bytes().ToString("#.#")}), try shrinking or compressing the image."); - public static PKError AvatarNotAnImage(string mimeType) => new PKError($"The given link does not point to an image{(mimeType != null ? $" ({mimeType})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif)."); - public static PKError AvatarDimensionsTooLarge(int width, int height) => new PKError($"Image too large ({width}x{height} > {Limits.AvatarDimensionLimit}x{Limits.AvatarDimensionLimit}), try resizing the image."); - public static PKError AvatarInvalid => new PKError($"Could not read image file - perhaps it's corrupted or the wrong format. Try a different image."); - public static PKError UserHasNoAvatar => new PKError("The given user has no avatar set."); - public static PKError InvalidUrl(string url) => new PKError($"The given URL is invalid."); - public static PKError UrlTooLong(string url) => new PKError($"The given URL is too long ({url.Length}/{Limits.MaxUriLength} characters)."); - - public static PKError AccountAlreadyLinked => new PKError("That account is already linked to your system."); - public static PKError AccountNotLinked => new PKError("That account isn't linked to your system."); - public static PKError AccountInOtherSystem(PKSystem system) => new PKError($"The mentioned account is already linked to another system (see `pk;system {system.Hid}`)."); - public static PKError UnlinkingLastAccount => new PKError("Since this is the only account linked to this system, you cannot unlink it (as that would leave your system account-less). If you would like to delete your system, use `pk;system delete`."); - public static PKError MemberLinkCancelled => new PKError("Member link cancelled."); - public static PKError MemberUnlinkCancelled => new PKError("Member unlink cancelled."); + public static PKError InvalidTimeZone(string zoneStr) => new( + $"Invalid time zone ID '{zoneStr}'. To find your time zone ID, use the following website: "); - public static PKError SameSwitch(ICollection members, LookupContext ctx) - { - if (members.Count == 0) return new PKError("There's already no one in front."); - if (members.Count == 1) return new PKError($"Member {members.First().NameFor(ctx)} is already fronting."); - return new PKError($"Members {string.Join(", ", members.Select(m => m.NameFor(ctx)))} are already fronting."); - } + public static PKError AmbiguousTimeZone(string zoneStr, int count) => new( + $"The time zone query '{zoneStr}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: "); - public static PKError DuplicateSwitchMembers => new PKError("Duplicate members in member list."); - public static PKError SwitchMemberNotInSystem => new PKError("One or more switch members aren't in your own system."); + public static PKError MessageNotFound(ulong id) => + new($"Message with ID '{id}' not found. Are you sure it's a message proxied by PluralKit?"); - public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str}' as a valid date/time. Try using a syntax such as \"May 21, 12:30 PM\" or \"3d12h\" (ie. 3 days, 12 hours ago)."); - public static PKError SwitchTimeInFuture => new PKError("Can't move switch to a time in the future."); - public static PKError NoRegisteredSwitches => new PKError("There are no registered switches for this system."); + public static PKError DurationParseError(string durationStr) => new( + $"Could not parse {durationStr.AsCode()} as a valid duration. Try a format such as `30d`, `1d3h` or `20m30s`."); - public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({time.FormatZoned()}), as it would cause conflicts."); - public static PKError SwitchMoveCancelled => new PKError("Switch move cancelled."); - public static PKError SwitchDeleteCancelled => new PKError("Switch deletion cancelled."); - public static PKError TimezoneParseError(string timezone) => new PKError($"Could not parse timezone offset {timezone}. Offset must be a value like 'UTC+5' or 'GMT-4:30'."); + public static PKError GuildNotFound(ulong guildId) => new( + $"Guild with ID `{guildId}` not found, or I cannot access it. Note that you must be a member of the guild you are querying."); - public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr}'. To find your time zone ID, use the following website: "); - public static PKError TimezoneChangeCancelled => new PKError("Time zone change cancelled."); - public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: "); - public static PKError NoImportFilePassed => new PKError("You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command."); - public static PKError InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); - public static PKError ImportCancelled => new PKError("Import cancelled."); - public static PKError MessageNotFound(ulong id) => new PKError($"Message with ID '{id}' not found. Are you sure it's a message proxied by PluralKit?"); - - public static PKError DurationParseError(string durationStr) => new PKError($"Could not parse {durationStr.AsCode()} as a valid duration. Try a format such as `30d`, `1d3h` or `20m30s`."); - public static PKError FrontPercentTimeInFuture => new PKError("Cannot get the front percent between now and a time in the future."); + public static PKError DisplayNameTooLong(string displayName, int maxLength) => new( + $"Display name too long ({displayName.Length} > {maxLength} characters). Use a shorter display name, or shorten your system tag."); - public static PKError GuildNotFound(ulong guildId) => new PKError($"Guild with ID {guildId} not found. Note that you must be a member of the guild you are querying."); + public static PKError ProxyNameTooShort(string name) => new( + $"The webhook's name, {name.AsCode()}, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag."); - public static PKError DisplayNameTooLong(string displayName, int maxLength) => new PKError( - $"Display name too long ({displayName.Length} > {maxLength} characters). Use a shorter display name, or shorten your system tag."); - public static PKError ProxyNameTooShort(string name) => new PKError($"The webhook's name, {name.AsCode()}, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag."); - public static PKError ProxyNameTooLong(string name) => new PKError($"The webhook's name, {name.AsCode()}, is too long ({name.Length} > {Limits.MaxProxyNameLength} characters), and thus cannot be proxied. Please change the member name, display name or server display name, or use a shorter system tag."); + public static PKError ProxyNameTooLong(string name) => new( + $"The webhook's name, {name.AsCode()}, is too long ({name.Length} > {Limits.MaxProxyNameLength} characters), and thus cannot be proxied. Please change the member name, display name or server display name, or use a shorter system tag."); - public static PKError ProxyTagAlreadyExists(ProxyTag tagToAdd, PKMember member) => new PKError($"That member already has the proxy tag {tagToAdd.ProxyString.AsCode()}. The member currently has these tags: {member.ProxyTagsString()}"); - public static PKError ProxyTagDoesNotExist(ProxyTag tagToRemove, PKMember member) => new PKError($"That member does not have the proxy tag {tagToRemove.ProxyString.AsCode()}. The member currently has these tags: {member.ProxyTagsString()}"); - public static PKError LegacyAlreadyHasProxyTag(ProxyTag requested, PKMember member) => new PKError($"This member already has more than one proxy tag set: {member.ProxyTagsString()}\nConsider using the {$"pk;member {member.Reference()} proxy add {requested.ProxyString}".AsCode()} command instead."); - public static PKError EmptyProxyTags(PKMember member) => new PKError($"The example proxy `text` is equivalent to having no proxy tags at all, since there are no symbols or brackets on either end. If you'd like to clear your proxy tags, use `pk;member {member.Reference()} proxy clear`."); + public static PKError ProxyTagAlreadyExists(ProxyTag tagToAdd, PKMember member) => new( + $"That member already has the proxy tag {tagToAdd.ProxyString.AsCode()}. The member currently has these tags: {member.ProxyTagsString()}"); - public static PKError GenericCancelled() => new PKError("Operation cancelled."); + public static PKError ProxyTagDoesNotExist(ProxyTag tagToRemove, PKMember member) => new( + $"That member does not have the proxy tag {tagToRemove.ProxyString.AsCode()}. The member currently has these tags: {member.ProxyTagsString()}"); - public static PKError AttachmentTooLarge => new PKError("PluralKit cannot proxy attachments over 8 megabytes (as webhooks aren't considered as having Discord Nitro) :("); - public static PKError LookupNotAllowed => new PKError("You do not have permission to access this information."); - public static PKError ChannelNotFound(string channelString) => new PKError($"Channel \"{channelString}\" not found or is not in this server."); - } + public static PKError LegacyAlreadyHasProxyTag(ProxyTag requested, PKMember member, Context ctx) => new( + $"This member already has more than one proxy tag set: {member.ProxyTagsString()}\nConsider using the {$"pk;member {member.Reference(ctx)} proxy add {requested.ProxyString}".AsCode()} command instead."); + + public static PKError EmptyProxyTags(PKMember member, Context ctx) => new( + $"The example proxy `text` is equivalent to having no proxy tags at all, since there are no symbols or brackets on either end. If you'd like to clear your proxy tags, use `pk;member {member.Reference(ctx)} proxy clear`."); + + public static PKError GenericCancelled() => new("Operation cancelled."); + + public static PKError AttachmentTooLarge(int mb) => new( + $"PluralKit cannot proxy attachments over {mb} megabytes in this server (as webhooks aren't considered as having Discord Nitro) :("); + + public static PKError ChannelNotFound(string channelString) => + new($"Channel \"{channelString}\" not found or is not in this server."); } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/IEventHandler.cs b/PluralKit.Bot/Handlers/IEventHandler.cs index 839eeba0..fd18849e 100644 --- a/PluralKit.Bot/Handlers/IEventHandler.cs +++ b/PluralKit.Bot/Handlers/IEventHandler.cs @@ -1,15 +1,10 @@ -using System.Threading.Tasks; +using Myriad.Gateway; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; +namespace PluralKit.Bot; -namespace PluralKit.Bot +public interface IEventHandler where T : IGatewayEvent { - public interface IEventHandler where T: DiscordEventArgs - { - Task Handle(DiscordClient shard, T evt); + Task Handle(int shardId, T evt); - DiscordChannel ErrorChannelFor(T evt) => null; - } + ulong? ErrorChannelFor(T evt, ulong userId) => null; } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/InteractionCreated.cs b/PluralKit.Bot/Handlers/InteractionCreated.cs new file mode 100644 index 00000000..25a9c0f8 --- /dev/null +++ b/PluralKit.Bot/Handlers/InteractionCreated.cs @@ -0,0 +1,31 @@ +using Autofac; + +using Myriad.Gateway; +using Myriad.Types; + +namespace PluralKit.Bot; + +public class InteractionCreated: IEventHandler +{ + private readonly InteractionDispatchService _interactionDispatch; + private readonly ILifetimeScope _services; + + public InteractionCreated(InteractionDispatchService interactionDispatch, ILifetimeScope services) + { + _interactionDispatch = interactionDispatch; + _services = services; + } + + public async Task Handle(int shardId, InteractionCreateEvent evt) + { + if (evt.Type == Interaction.InteractionType.MessageComponent) + { + var customId = evt.Data?.CustomId; + if (customId != null) + { + var ctx = new InteractionContext(evt, _services); + await _interactionDispatch.Dispatch(customId, ctx); + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index d51fe0fc..09a1a8c5 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -1,153 +1,198 @@ -using System; -using System.Threading.Tasks; - using App.Metrics; using Autofac; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Types.Requests; +using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class MessageCreated: IEventHandler { - public class MessageCreated: IEventHandler + private readonly Bot _bot; + private readonly IDiscordCache _cache; + private readonly Cluster _cluster; + private readonly BotConfig _config; + private readonly IDatabase _db; + private readonly LastMessageCacheService _lastMessageCache; + private readonly LoggerCleanService _loggerClean; + private readonly IMetrics _metrics; + private readonly ProxyService _proxy; + private readonly ModelRepository _repo; + private readonly DiscordApiClient _rest; + private readonly ILifetimeScope _services; + private readonly CommandTree _tree; + private readonly PrivateChannelService _dmCache; + + public MessageCreated(LastMessageCacheService lastMessageCache, LoggerCleanService loggerClean, + IMetrics metrics, ProxyService proxy, + CommandTree tree, ILifetimeScope services, IDatabase db, BotConfig config, + ModelRepository repo, IDiscordCache cache, + Bot bot, Cluster cluster, DiscordApiClient rest, PrivateChannelService dmCache) { - private readonly CommandTree _tree; - private readonly DiscordShardedClient _client; - private readonly LastMessageCacheService _lastMessageCache; - private readonly LoggerCleanService _loggerClean; - private readonly IMetrics _metrics; - private readonly ProxyService _proxy; - private readonly ILifetimeScope _services; - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly BotConfig _config; + _lastMessageCache = lastMessageCache; + _loggerClean = loggerClean; + _metrics = metrics; + _proxy = proxy; + _tree = tree; + _services = services; + _db = db; + _config = config; + _repo = repo; + _cache = cache; + _bot = bot; + _cluster = cluster; + _rest = rest; + _dmCache = dmCache; + } - public MessageCreated(LastMessageCacheService lastMessageCache, LoggerCleanService loggerClean, - IMetrics metrics, ProxyService proxy, DiscordShardedClient client, - CommandTree tree, ILifetimeScope services, IDatabase db, BotConfig config, ModelRepository repo) + public ulong? ErrorChannelFor(MessageCreateEvent evt, ulong userId) => evt.ChannelId; + private bool IsDuplicateMessage(Message msg) => + // We consider a message duplicate if it has the same ID as the previous message that hit the gateway + _lastMessageCache.GetLastMessage(msg.ChannelId)?.Current.Id == msg.Id; + + public async Task Handle(int shardId, MessageCreateEvent evt) + { + if (evt.Author.Id == await _cache.GetOwnUser()) return; + if (evt.Type != Message.MessageType.Default && evt.Type != Message.MessageType.Reply) return; + if (IsDuplicateMessage(evt)) return; + + if (!(await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.SendMessages)) return; + + // spawn off saving the private channel into another thread + // it is not a fatal error if this fails, and it shouldn't block message processing + _ = _dmCache.TrySavePrivateChannel(evt); + + var guild = evt.GuildId != null ? await _cache.GetGuild(evt.GuildId.Value) : null; + var channel = await _cache.GetChannel(evt.ChannelId); + var rootChannel = await _cache.GetRootChannel(evt.ChannelId); + + // Log metrics and message info + _metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived); + _lastMessageCache.AddMessage(evt); + + // Get message context from DB (tracking w/ metrics) + MessageContext ctx; + using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) + ctx = await _repo.GetMessageContext(evt.Author.Id, evt.GuildId ?? default, rootChannel.Id); + + // Try each handler until we find one that succeeds + if (await TryHandleLogClean(evt, ctx)) + return; + + // Only do command/proxy handling if it's a user account + if (evt.Author.Bot || evt.WebhookId != null || evt.Author.System == true) + return; + + if (await TryHandleCommand(shardId, evt, guild, channel, ctx)) + return; + await TryHandleProxy(evt, guild, channel, ctx); + } + + private async ValueTask TryHandleLogClean(MessageCreateEvent evt, MessageContext ctx) + { + var channel = await _cache.GetChannel(evt.ChannelId); + if (!evt.Author.Bot || channel.Type != Channel.ChannelType.GuildText || + !ctx.LogCleanupEnabled) return false; + + await _loggerClean.HandleLoggerBotCleanup(evt); + return true; + } + + private async ValueTask TryHandleCommand(int shardId, MessageCreateEvent evt, Guild? guild, + Channel channel, MessageContext ctx) + { + var content = evt.Content; + if (content == null) return false; + + var ourUserId = await _cache.GetOwnUser(); + + // Check for command prefix + if (!HasCommandPrefix(content, ourUserId, out var cmdStart) || cmdStart == content.Length) + return false; + + if (ctx.IsDeleting) { - _lastMessageCache = lastMessageCache; - _loggerClean = loggerClean; - _metrics = metrics; - _proxy = proxy; - _client = client; - _tree = tree; - _services = services; - _db = db; - _config = config; - _repo = repo; - } - - public DiscordChannel ErrorChannelFor(MessageCreateEventArgs evt) => evt.Channel; - - private bool IsDuplicateMessage(DiscordMessage evt) => - // We consider a message duplicate if it has the same ID as the previous message that hit the gateway - _lastMessageCache.GetLastMessage(evt.ChannelId) == evt.Id; - - public async Task Handle(DiscordClient shard, MessageCreateEventArgs evt) - { - if (evt.Author?.Id == _client.CurrentUser?.Id) return; - if (evt.Message.MessageType != MessageType.Default) return; - if (IsDuplicateMessage(evt.Message)) return; - - // Log metrics and message info - _metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived); - _lastMessageCache.AddMessage(evt.Channel.Id, evt.Message.Id); - - // Get message context from DB (tracking w/ metrics) - MessageContext ctx; - await using (var conn = await _db.Obtain()) - using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) - ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id); - - // Try each handler until we find one that succeeds - if (await TryHandleLogClean(evt, ctx)) - return; - - // Only do command/proxy handling if it's a user account - if (evt.Message.Author.IsBot || evt.Message.WebhookMessage || evt.Message.Author.IsSystem == true) - return; - if (await TryHandleCommand(shard, evt, ctx)) - return; - await TryHandleProxy(shard, evt, ctx); - } - - private async ValueTask TryHandleLogClean(MessageCreateEventArgs evt, MessageContext ctx) - { - if (!evt.Message.Author.IsBot || evt.Message.Channel.Type != ChannelType.Text || - !ctx.LogCleanupEnabled) return false; - - await _loggerClean.HandleLoggerBotCleanup(evt.Message); + await _rest.CreateMessage(evt.ChannelId, new() + { + Content = $"{Emojis.Error} Your system is currently being deleted." + + " Due to database issues, it is not possible to use commands while a system is being deleted. Please wait a few minutes and try again.", + MessageReference = new(guild?.Id, channel.Id, evt.Id) + }); return true; } - private async ValueTask TryHandleCommand(DiscordClient shard, MessageCreateEventArgs evt, MessageContext ctx) + // Trim leading whitespace from command without actually modifying the string + // This just moves the argPos pointer by however much whitespace is at the start of the post-argPos string + var trimStartLengthDiff = + content.Substring(cmdStart).Length - content.Substring(cmdStart).TrimStart().Length; + cmdStart += trimStartLengthDiff; + + try { - var content = evt.Message.Content; - if (content == null) return false; + var system = ctx.SystemId != null ? await _repo.GetSystem(ctx.SystemId.Value) : null; + var config = ctx.SystemId != null ? await _repo.GetSystemConfig(ctx.SystemId.Value) : null; + await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, ctx)); + } + catch (PKError) + { + // Only permission errors will ever bubble this far and be caught here instead of Context.Execute + // so we just catch and ignore these. TODO: this may need to change. + } - // Check for command prefix - if (!HasCommandPrefix(content, out var cmdStart)) - return false; + return true; + } - // Trim leading whitespace from command without actually modifying the string - // This just moves the argPos pointer by however much whitespace is at the start of the post-argPos string - var trimStartLengthDiff = content.Substring(cmdStart).Length - content.Substring(cmdStart).TrimStart().Length; - cmdStart += trimStartLengthDiff; - - try - { - var system = ctx.SystemId != null ? await _db.Execute(c => _repo.GetSystem(c, ctx.SystemId.Value)) : null; - await _tree.ExecuteCommand(new Context(_services, shard, evt.Message, cmdStart, system, ctx)); - } - catch (PKError) - { - // Only permission errors will ever bubble this far and be caught here instead of Context.Execute - // so we just catch and ignore these. TODO: this may need to change. - } + private bool HasCommandPrefix(string message, ulong currentUserId, out int argPos) + { + // First, try prefixes defined in the config + var prefixes = _config.Prefixes ?? BotConfig.DefaultPrefixes; + foreach (var prefix in prefixes) + { + if (!message.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) continue; + argPos = prefix.Length; return true; } - private bool HasCommandPrefix(string message, out int argPos) - { - // First, try prefixes defined in the config - var prefixes = _config.Prefixes ?? BotConfig.DefaultPrefixes; - foreach (var prefix in prefixes) - { - if (!message.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) continue; - - argPos = prefix.Length; - return true; - } - - // Then, check mention prefix (must be the bot user, ofc) - argPos = -1; - if (DiscordUtils.HasMentionPrefix(message, ref argPos, out var id)) - return id == _client.CurrentUser.Id; + // Then, check mention prefix (must be the bot user, ofc) + argPos = -1; + if (DiscordUtils.HasMentionPrefix(message, ref argPos, out var id)) + return id == currentUserId; - return false; + return false; + } + + private async ValueTask TryHandleProxy(MessageCreateEvent evt, Guild guild, Channel channel, + MessageContext ctx) + { + if (ctx.IsDeleting) return false; + + var botPermissions = await _cache.PermissionsIn(channel.Id); + + try + { + return await _proxy.HandleIncomingMessage(evt, ctx, guild, channel, ctx.AllowAutoproxy, + botPermissions); } - private async ValueTask TryHandleProxy(DiscordClient shard, MessageCreateEventArgs evt, MessageContext ctx) - { - try - { - return await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: ctx.AllowAutoproxy); - } - catch (PKError e) - { - // User-facing errors, print to the channel properly formatted - var msg = evt.Message; - if (msg.Channel.Guild == null || msg.Channel.BotHasAllPermissions(Permissions.SendMessages)) - await msg.Channel.SendMessageFixedAsync($"{Emojis.Error} {e.Message}"); - } + // Catch any failed proxy checks so they get ignored in the global error handler + catch (ProxyService.ProxyChecksFailedException) { } - return false; + catch (PKError e) + { + // User-facing errors, print to the channel properly formatted + if (botPermissions.HasFlag(PermissionSet.SendMessages)) + await _rest.CreateMessage(evt.ChannelId, + new MessageRequest { Content = $"{Emojis.Error} {e.Message}" }); } + + return false; } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageDeleted.cs b/PluralKit.Bot/Handlers/MessageDeleted.cs index f3a5cf70..408c363d 100644 --- a/PluralKit.Bot/Handlers/MessageDeleted.cs +++ b/PluralKit.Bot/Handlers/MessageDeleted.cs @@ -1,61 +1,63 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - -using DSharpPlus; -using DSharpPlus.EventArgs; +using Myriad.Gateway; using PluralKit.Core; using Serilog; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +// Double duty :) +public class MessageDeleted: IEventHandler, IEventHandler { - // Double duty :) - public class MessageDeleted: IEventHandler, IEventHandler + private static readonly TimeSpan MessageDeleteDelay = TimeSpan.FromSeconds(15); + + private readonly IDatabase _db; + private readonly ModelRepository _repo; + private readonly ILogger _logger; + private readonly LastMessageCacheService _lastMessage; + + public MessageDeleted(ILogger logger, IDatabase db, ModelRepository repo, LastMessageCacheService lastMessage) { - private static readonly TimeSpan MessageDeleteDelay = TimeSpan.FromSeconds(15); - - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly ILogger _logger; - - public MessageDeleted(ILogger logger, IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - _logger = logger.ForContext(); - } - - public Task Handle(DiscordClient shard, MessageDeleteEventArgs evt) - { - // Delete deleted webhook messages from the data store - // Most of the data in the given message is wrong/missing, so always delete just to be sure. - - async Task Inner() - { - await Task.Delay(MessageDeleteDelay); - await _db.Execute(c => _repo.DeleteMessage(c, evt.Message.Id)); - } - - // Fork a task to delete the message after a short delay - // to allow for lookups to happen for a little while after deletion - _ = Inner(); - return Task.CompletedTask; - } - - public Task Handle(DiscordClient shard, MessageBulkDeleteEventArgs evt) - { - // Same as above, but bulk - async Task Inner() - { - await Task.Delay(MessageDeleteDelay); - _logger.Information("Bulk deleting {Count} messages in channel {Channel}", evt.Messages.Count, evt.Channel.Id); - await _db.Execute(c => _repo.DeleteMessagesBulk(c, evt.Messages.Select(m => m.Id).ToList())); - } - - _ = Inner(); - return Task.CompletedTask; - } + _db = db; + _repo = repo; + _lastMessage = lastMessage; + _logger = logger.ForContext(); } + + public Task Handle(int shardId, MessageDeleteEvent evt) + { + // Delete deleted webhook messages from the data store + // Most of the data in the given message is wrong/missing, so always delete just to be sure. + + async Task Inner() + { + await Task.Delay(MessageDeleteDelay); + await _repo.DeleteMessage(evt.Id); + } + + _lastMessage.HandleMessageDeletion(evt.ChannelId, evt.Id); + + // Fork a task to delete the message after a short delay + // to allow for lookups to happen for a little while after deletion + _ = Inner(); + return Task.CompletedTask; + } + + public Task Handle(int shardId, MessageDeleteBulkEvent evt) + { + // Same as above, but bulk + async Task Inner() + { + await Task.Delay(MessageDeleteDelay); + + _logger.Information("Bulk deleting {Count} messages in channel {Channel}", + evt.Ids.Length, evt.ChannelId); + await _repo.DeleteMessagesBulk(evt.Ids); + } + + _lastMessage.HandleMessageDeletion(evt.ChannelId, evt.Ids.ToList()); + _ = Inner(); + return Task.CompletedTask; + } + } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageEdited.cs b/PluralKit.Bot/Handlers/MessageEdited.cs index ac627ef0..5e271d9f 100644 --- a/PluralKit.Bot/Handlers/MessageEdited.cs +++ b/PluralKit.Bot/Handlers/MessageEdited.cs @@ -1,50 +1,126 @@ -using System.Threading.Tasks; - using App.Metrics; -using DSharpPlus; -using DSharpPlus.EventArgs; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Types; using PluralKit.Core; +using Serilog; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class MessageEdited: IEventHandler { - public class MessageEdited: IEventHandler + private readonly Bot _bot; + private readonly IDiscordCache _cache; + private readonly Cluster _client; + private readonly IDatabase _db; + private readonly LastMessageCacheService _lastMessageCache; + private readonly ILogger _logger; + private readonly IMetrics _metrics; + private readonly ProxyService _proxy; + private readonly ModelRepository _repo; + private readonly DiscordApiClient _rest; + + public MessageEdited(LastMessageCacheService lastMessageCache, ProxyService proxy, IDatabase db, + IMetrics metrics, ModelRepository repo, Cluster client, IDiscordCache cache, Bot bot, + DiscordApiClient rest, ILogger logger) { - private readonly LastMessageCacheService _lastMessageCache; - private readonly ProxyService _proxy; - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly IMetrics _metrics; - private readonly DiscordShardedClient _client; + _lastMessageCache = lastMessageCache; + _proxy = proxy; + _db = db; + _metrics = metrics; + _repo = repo; + _client = client; + _cache = cache; + _bot = bot; + _rest = rest; + _logger = logger.ForContext(); + } - public MessageEdited(LastMessageCacheService lastMessageCache, ProxyService proxy, IDatabase db, IMetrics metrics, ModelRepository repo, DiscordShardedClient client) + public async Task Handle(int shardId, MessageUpdateEvent evt) + { + if (evt.Author.Value?.Id == await _cache.GetOwnUser()) return; + + // Edit message events sometimes arrive with missing data; double-check it's all there + if (!evt.Content.HasValue || !evt.Author.HasValue || !evt.Member.HasValue) + return; + + var channel = await _cache.GetChannel(evt.ChannelId); + if (!DiscordUtils.IsValidGuildChannel(channel)) + return; + var guild = await _cache.GetGuild(channel.GuildId!.Value); + var lastMessage = _lastMessageCache.GetLastMessage(evt.ChannelId)?.Current; + + // Only react to the last message in the channel + if (lastMessage?.Id != evt.Id) + return; + + // Just run the normal message handling code, with a flag to disable autoproxying + MessageContext ctx; + using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) + ctx = await _repo.GetMessageContext(evt.Author.Value!.Id, channel.GuildId!.Value, evt.ChannelId); + + var equivalentEvt = await GetMessageCreateEvent(evt, lastMessage, channel); + var botPermissions = await _cache.PermissionsIn(channel.Id); + + try { - _lastMessageCache = lastMessageCache; - _proxy = proxy; - _db = db; - _metrics = metrics; - _repo = repo; - _client = client; + await _proxy.HandleIncomingMessage(equivalentEvt, ctx, allowAutoproxy: false, guild: guild, + channel: channel, botPermissions: botPermissions); + } + // Catch any failed proxy checks so they get ignored in the global error handler + catch (ProxyService.ProxyChecksFailedException) { } + } + + private async Task GetMessageCreateEvent(MessageUpdateEvent evt, CachedMessage lastMessage, + Channel channel) + { + var referencedMessage = await GetReferencedMessage(evt.ChannelId, lastMessage.ReferencedMessage); + + var messageReference = lastMessage.ReferencedMessage != null + ? new Message.Reference(channel.GuildId, evt.ChannelId, lastMessage.ReferencedMessage.Value) + : null; + + var messageType = lastMessage.ReferencedMessage != null + ? Message.MessageType.Reply + : Message.MessageType.Default; + + // TODO: is this missing anything? + var equivalentEvt = new MessageCreateEvent + { + Id = evt.Id, + ChannelId = evt.ChannelId, + GuildId = channel.GuildId, + Author = evt.Author.Value, + Member = evt.Member.Value, + Content = evt.Content.Value, + Attachments = evt.Attachments.Value ?? Array.Empty(), + MessageReference = messageReference, + ReferencedMessage = referencedMessage, + Type = messageType, + }; + return equivalentEvt; + } + + private async Task GetReferencedMessage(ulong channelId, ulong? referencedMessageId) + { + if (referencedMessageId == null) + return null; + + var botPermissions = await _cache.PermissionsIn(channelId); + if (!botPermissions.HasFlag(PermissionSet.ReadMessageHistory)) + { + _logger.Warning( + "Tried to get referenced message in channel {ChannelId} to reply but bot does not have Read Message History", + channelId + ); + return null; } - public async Task Handle(DiscordClient shard, MessageUpdateEventArgs evt) - { - if (evt.Author?.Id == _client.CurrentUser?.Id) return; - - // Edit message events sometimes arrive with missing data; double-check it's all there - if (evt.Message.Content == null || evt.Author == null || evt.Channel.Guild == null) return; - - // Only react to the last message in the channel - if (_lastMessageCache.GetLastMessage(evt.Channel.Id) != evt.Message.Id) return; - - // Just run the normal message handling code, with a flag to disable autoproxying - MessageContext ctx; - await using (var conn = await _db.Obtain()) - using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) - ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id); - await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: false); - } + return await _rest.GetMessage(channelId, referencedMessageId.Value); } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/ReactionAdded.cs b/PluralKit.Bot/Handlers/ReactionAdded.cs index b3f29ea4..482aa39c 100644 --- a/PluralKit.Bot/Handlers/ReactionAdded.cs +++ b/PluralKit.Bot/Handlers/ReactionAdded.cs @@ -1,203 +1,260 @@ -using System.Threading.Tasks; - -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; -using DSharpPlus.Exceptions; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Exceptions; +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; using PluralKit.Core; +using NodaTime; + using Serilog; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class ReactionAdded: IEventHandler { - public class ReactionAdded: IEventHandler + private readonly Bot _bot; + private readonly IDiscordCache _cache; + private readonly Cluster _cluster; + private readonly CommandMessageService _commandMessageService; + private readonly IDatabase _db; + private readonly EmbedService _embeds; + private readonly ILogger _logger; + private readonly ModelRepository _repo; + private readonly DiscordApiClient _rest; + private readonly PrivateChannelService _dmCache; + + public ReactionAdded(ILogger logger, IDatabase db, ModelRepository repo, + CommandMessageService commandMessageService, IDiscordCache cache, Bot bot, Cluster cluster, + DiscordApiClient rest, EmbedService embeds, PrivateChannelService dmCache) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly CommandMessageService _commandMessageService; - private readonly EmbedService _embeds; - private readonly ILogger _logger; + _db = db; + _repo = repo; + _commandMessageService = commandMessageService; + _cache = cache; + _bot = bot; + _cluster = cluster; + _rest = rest; + _embeds = embeds; + _logger = logger.ForContext(); + _dmCache = dmCache; + } - public ReactionAdded(EmbedService embeds, ILogger logger, IDatabase db, ModelRepository repo, CommandMessageService commandMessageService) + public async Task Handle(int shardId, MessageReactionAddEvent evt) + { + await TryHandleProxyMessageReactions(evt); + } + + private async ValueTask TryHandleProxyMessageReactions(MessageReactionAddEvent evt) + { + // ignore any reactions added by *us* + if (evt.UserId == await _cache.GetOwnUser()) + return; + + // Ignore reactions from bots (we can't DM them anyway) + // note: this used to get from cache since this event does not contain Member in DMs + // but we aren't able to get DMs from bots anyway, so it's not really needed + if (evt.GuildId != null && evt.Member.User.Bot) return; + + var channel = await _cache.GetChannel(evt.ChannelId); + + // check if it's a command message first + // since this can happen in DMs as well + if (evt.Emoji.Name == "\u274c") { - _embeds = embeds; - _db = db; - _repo = repo; - _commandMessageService = commandMessageService; - _logger = logger.ForContext(); - } - - public async Task Handle(DiscordClient shard, MessageReactionAddEventArgs evt) - { - await TryHandleProxyMessageReactions(shard, evt); - } - - private async ValueTask TryHandleProxyMessageReactions(DiscordClient shard, MessageReactionAddEventArgs evt) - { - - // Sometimes we get events from users that aren't in the user cache - // In that case we get a "broken" user object (where eg. calling IsBot throws an exception) - // We just ignore all of those for now, should be quite rare... - if (!shard.TryGetCachedUser(evt.User.Id, out _)) return; - - // check if it's a command message first - // since this can happen in DMs as well - if (evt.Emoji.Name == "\u274c") + // in DMs, allow deleting any PK message + if (channel.GuildId == null) { - await using var conn = await _db.Obtain(); - var commandMsg = await _commandMessageService.GetCommandMessage(conn, evt.Message.Id); - if (commandMsg != null) - { - await HandleCommandDeleteReaction(evt, commandMsg); - return; - } + await HandleCommandDeleteReaction(evt, null); + return; } - // Only proxies in guild text channels - if (evt.Channel == null || evt.Channel.Type != ChannelType.Text) return; - - // Ignore reactions from bots (we can't DM them anyway) - if (evt.User.IsBot) return; - - switch (evt.Emoji.Name) + var commandMsg = await _commandMessageService.GetCommandMessage(evt.MessageId); + if (commandMsg != null) { - // Message deletion - case "\u274C": // Red X + await HandleCommandDeleteReaction(evt, commandMsg); + return; + } + } + + // Proxied messages only exist in guild text channels, so skip checking if we're elsewhere + if (!DiscordUtils.IsValidGuildChannel(channel)) return; + + switch (evt.Emoji.Name) + { + // Message deletion + case "\u274C": // Red X { - await using var conn = await _db.Obtain(); - var msg = await _repo.GetMessage(conn, evt.Message.Id); + var msg = await _db.Execute(c => _repo.GetMessage(c, evt.MessageId)); if (msg != null) await HandleProxyDeleteReaction(evt, msg); - + break; } - case "\u2753": // Red question mark - case "\u2754": // White question mark + case "\u2753": // Red question mark + case "\u2754": // White question mark { - await using var conn = await _db.Obtain(); - var msg = await _repo.GetMessage(conn, evt.Message.Id); + var msg = await _db.Execute(c => _repo.GetMessage(c, evt.MessageId)); if (msg != null) - await HandleQueryReaction(shard, evt, msg); - + await HandleQueryReaction(evt, msg); + break; } - case "\U0001F514": // Bell - case "\U0001F6CE": // Bellhop bell - case "\U0001F3D3": // Ping pong paddle (lol) - case "\u23F0": // Alarm clock - case "\u2757": // Exclamation mark + case "\U0001F514": // Bell + case "\U0001F6CE": // Bellhop bell + case "\U0001F3D3": // Ping pong paddle (lol) + case "\u23F0": // Alarm clock + case "\u2757": // Exclamation mark { - await using var conn = await _db.Obtain(); - var msg = await _repo.GetMessage(conn, evt.Message.Id); + var msg = await _db.Execute(c => _repo.GetMessage(c, evt.MessageId)); if (msg != null) await HandlePingReaction(evt, msg); break; } - } - } - - private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEventArgs evt, FullMessage msg) - { - if (!evt.Channel.BotHasAllPermissions(Permissions.ManageMessages)) return; - - // Can only delete your own message - if (msg.Message.Sender != evt.User.Id) return; - - try - { - await evt.Message.DeleteAsync(); - } - catch (NotFoundException) - { - // Message was deleted by something/someone else before we got to it - } - - await _db.Execute(c => _repo.DeleteMessage(c, evt.Message.Id)); - } - - private async ValueTask HandleCommandDeleteReaction(MessageReactionAddEventArgs evt, CommandMessage msg) - { - if (!evt.Channel.BotHasAllPermissions(Permissions.ManageMessages) && evt.Channel.Guild != null) - return; - - // Can only delete your own message - if (msg.AuthorId != evt.User.Id) - return; - - try - { - await evt.Message.DeleteAsync(); - } - catch (NotFoundException) - { - // Message was deleted by something/someone else before we got to it - } - - // No need to delete database row here, it'll get deleted by the once-per-minute scheduled task. - } - - private async ValueTask HandleQueryReaction(DiscordClient shard, MessageReactionAddEventArgs evt, FullMessage msg) - { - // Try to DM the user info about the message - var member = await evt.Guild.GetMember(evt.User.Id); - try - { - await member.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, evt.Guild, LookupContext.ByNonOwner)); - await member.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(shard, msg)); - } - catch (UnauthorizedException) { } // No permissions to DM, can't check for this :( - - await TryRemoveOriginalReaction(evt); - } - - private async ValueTask HandlePingReaction(MessageReactionAddEventArgs evt, FullMessage msg) - { - if (!evt.Channel.BotHasAllPermissions(Permissions.SendMessages)) return; - - // Check if the "pinger" has permission to send messages in this channel - // (if not, PK shouldn't send messages on their behalf) - var guildUser = await evt.Guild.GetMember(evt.User.Id); - var requiredPerms = Permissions.AccessChannels | Permissions.SendMessages; - if (guildUser == null || (guildUser.PermissionsIn(evt.Channel) & requiredPerms) != requiredPerms) return; - - if (msg.System.PingsEnabled) - { - // If the system has pings enabled, go ahead - var embed = new DiscordEmbedBuilder().WithDescription($"[Jump to pinged message]({evt.Message.JumpLink})"); - await evt.Channel.SendMessageFixedAsync($"Psst, **{msg.Member.DisplayName()}** (<@{msg.Message.Sender}>), you have been pinged by <@{evt.User.Id}>.", embed: embed.Build(), - new IMention[] {new UserMention(msg.Message.Sender) }); - } - else - { - // If not, tell them in DMs (if we can) - try - { - await guildUser.SendMessageFixedAsync($"{Emojis.Error} {msg.Member.DisplayName()}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:"); - await guildUser.SendMessageFixedAsync($"<@{msg.Message.Sender}>".AsCode()); - } - catch (UnauthorizedException) { } - } - - await TryRemoveOriginalReaction(evt); - } - - private async Task TryRemoveOriginalReaction(MessageReactionAddEventArgs evt) - { - try - { - if (evt.Channel.BotHasAllPermissions(Permissions.ManageMessages)) - await evt.Message.DeleteReactionAsync(evt.Emoji, evt.User); - } - catch (UnauthorizedException) - { - var botPerms = evt.Channel.BotPermissions(); - // So, in some cases (see Sentry issue 11K) the above check somehow doesn't work, and - // Discord returns a 403 Unauthorized. TODO: figure out the root cause here instead of a workaround - _logger.Warning("Attempted to remove reaction {Emoji} from user {User} on message {Channel}/{Message}, but got 403. Bot has permissions {Permissions} according to itself.", - evt.Emoji.Id, evt.User.Id, evt.Channel.Id, evt.Message.Id, botPerms); - } } } + + private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEvent evt, FullMessage msg) + { + if (!(await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages)) + return; + + var system = await _repo.GetSystemByAccount(evt.UserId); + + // Can only delete your own message + if (msg.System?.Id != system?.Id && msg.Message.Sender != evt.UserId) return; + + try + { + await _rest.DeleteMessage(evt.ChannelId, evt.MessageId); + } + catch (NotFoundException) + { + // Message was deleted by something/someone else before we got to it + } + + await _repo.DeleteMessage(evt.MessageId); + } + + private async ValueTask HandleCommandDeleteReaction(MessageReactionAddEvent evt, CommandMessage? msg) + { + // Can only delete your own message + // (except in DMs, where msg will be null) + if (msg != null && msg.AuthorId != evt.UserId) + return; + + // todo: don't try to delete the user's own messages in DMs + // this is hard since we don't have the message author object, but it happens infrequently enough to not really care about the 403s, I guess? + + try + { + await _rest.DeleteMessage(evt.ChannelId, evt.MessageId); + } + catch (NotFoundException) + { + // Message was deleted by something/someone else before we got to it + } + + // No need to delete database row here, it'll get deleted by the once-per-minute scheduled task. + } + + private async ValueTask HandleQueryReaction(MessageReactionAddEvent evt, FullMessage msg) + { + var guild = await _cache.GetGuild(evt.GuildId!.Value); + + // Try to DM the user info about the message + try + { + var dm = await _dmCache.GetOrCreateDmChannel(evt.UserId); + + var embeds = new List(); + + if (msg.Member != null) + embeds.Add(await _embeds.CreateMemberEmbed( + msg.System, + msg.Member, + guild, + LookupContext.ByNonOwner, + DateTimeZone.Utc + )); + + embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, true)); + + await _rest.CreateMessage(dm, new MessageRequest { Embeds = embeds.ToArray() }); + } + catch (ForbiddenException) { } // No permissions to DM, can't check for this :( + + await TryRemoveOriginalReaction(evt); + } + + private async ValueTask HandlePingReaction(MessageReactionAddEvent evt, FullMessage msg) + { + if (!(await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages)) + return; + + // Check if the "pinger" has permission to send messages in this channel + // (if not, PK shouldn't send messages on their behalf) + var member = await _rest.GetGuildMember(evt.GuildId!.Value, evt.UserId); + var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages; + if (member == null || !(await _cache.PermissionsFor(evt.ChannelId, member)).HasFlag(requiredPerms)) return; + + if (msg.Member == null) return; + + var config = await _repo.GetSystemConfig(msg.System.Id); + + if (config.PingsEnabled) + // If the system has pings enabled, go ahead + await _rest.CreateMessage(evt.ChannelId, new MessageRequest + { + Content = $"Psst, **{msg.Member.DisplayName()}** (<@{msg.Message.Sender}>), you have been pinged by <@{evt.UserId}>.", + Components = new[] + { + new MessageComponent + { + Type = ComponentType.ActionRow, + Components = new[] + { + new MessageComponent + { + Style = ButtonStyle.Link, + Type = ComponentType.Button, + Label = "Jump", + Url = evt.JumpLink() + } + } + } + }, + AllowedMentions = new AllowedMentions { Users = new[] { msg.Message.Sender } } + }); + else + // If not, tell them in DMs (if we can) + try + { + var dm = await _dmCache.GetOrCreateDmChannel(evt.UserId); + await _rest.CreateMessage(dm, + new MessageRequest + { + Content = + $"{Emojis.Error} {msg.Member.DisplayName()}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:" + }); + await _rest.CreateMessage( + dm, + new MessageRequest { Content = $"<@{msg.Message.Sender}>".AsCode() } + ); + } + catch (ForbiddenException) { } + + await TryRemoveOriginalReaction(evt); + } + + private async Task TryRemoveOriginalReaction(MessageReactionAddEvent evt) + { + if ((await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages)) + await _rest.DeleteUserReaction(evt.ChannelId, evt.MessageId, evt.Emoji, evt.UserId); + } } \ No newline at end of file diff --git a/PluralKit.Bot/Init.cs b/PluralKit.Bot/Init.cs index f3255434..57af88a8 100644 --- a/PluralKit.Bot/Init.cs +++ b/PluralKit.Bot/Init.cs @@ -1,129 +1,204 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - using Autofac; -using DSharpPlus; - using Microsoft.Extensions.Configuration; +using Myriad.Cache; +using Myriad.Gateway; +using Myriad.Types; +using Myriad.Rest; + using PluralKit.Core; +using Sentry; + using Serilog; using Serilog.Core; -namespace PluralKit.Bot -{ - public class Init - { - static Task Main(string[] args) - { - // Load configuration and run global init stuff - var config = InitUtils.BuildConfiguration(args).Build(); - InitUtils.InitStatic(); - - // Set up DI container and modules - var services = BuildContainer(config); - - return RunWrapper(services, async ct => - { - var logger = services.Resolve().ForContext(); - - // Initialize Sentry SDK, and make sure it gets dropped at the end - using var _ = Sentry.SentrySdk.Init(services.Resolve().SentryUrl); +namespace PluralKit.Bot; +public class Init +{ + private static async Task Main(string[] args) + { + // Load configuration and run global init stuff + var config = InitUtils.BuildConfiguration(args).Build(); + InitUtils.InitStatic(); + + // init version service + await BuildInfoService.LoadVersion(); + + // Set up DI container and modules + var services = BuildContainer(config); + + await RunWrapper(services, async ct => + { + var logger = services.Resolve().ForContext(); + + // Initialize Sentry SDK, and make sure it gets dropped at the end + + using var _ = SentrySdk.Init(opts => + { + opts.Dsn = services.Resolve().SentryUrl; + opts.Release = BuildInfoService.FullVersion; + opts.AutoSessionTracking = true; + opts.DisableTaskUnobservedTaskExceptionCapture(); + }); + + var config = services.Resolve(); + var coreConfig = services.Resolve(); + + // initialize Redis + var redis = services.Resolve(); + if (config.UseRedisRatelimiter) + await redis.InitAsync(coreConfig); + + var cache = services.Resolve(); + if (cache is RedisDiscordCache) + await (cache as RedisDiscordCache).InitAsync(coreConfig.RedisAddr, config.ClientId!.Value); + + if (config.Cluster == null) + { // "Connect to the database" (ie. set off database migrations and ensure state) logger.Information("Connecting to database"); await services.Resolve().ApplyMigrations(); - - // Init the bot instance itself, register handlers and such to the client before beginning to connect - logger.Information("Initializing bot"); - var bot = services.Resolve(); - bot.Init(); - - // Install observer for request/responses - DiscordRequestObserver.Install(services); - // Start the Discord shards themselves (handlers already set up) - logger.Information("Connecting to Discord"); - await services.Resolve().StartAsync(); - logger.Information("Connected! All is good (probably)."); + // Clear shard status from Redis + if (redis.Connection != null) + await redis.Connection.GetDatabase().KeyDeleteAsync("pluralkit:shardstatus"); + } - // Lastly, we just... wait. Everything else is handled in the DiscordClient event loop - try - { - await Task.Delay(-1, ct); - } - catch (TaskCanceledException) - { - // Once the CancellationToken fires, we need to shut stuff down - // (generally happens given a SIGINT/SIGKILL/Ctrl-C, see calling wrapper) - await bot.Shutdown(); - } - }); - } + logger.Information("Initializing bot"); + var bot = services.Resolve(); - private static async Task RunWrapper(IContainer services, Func taskFunc) - { - // This function does a couple things: - // - Creates a CancellationToken that'll cancel tasks once needed - // - Wraps the given function in an exception handler that properly logs errors - // - Adds a SIGINT (Ctrl-C) listener through Console.CancelKeyPress to gracefully shut down - // - Adds a SIGTERM (kill, systemctl stop, docker stop) listener through AppDomain.ProcessExit (same as above) - var logger = services.Resolve().ForContext(); + // Get bot status message from Redis + if (redis.Connection != null) + bot.CustomStatusMessage = await redis.Connection.GetDatabase().StringGetAsync("pluralkit:botstatus"); - var shutdown = new TaskCompletionSource(); - var gracefulShutdownCts = new CancellationTokenSource(); - - Console.CancelKeyPress += delegate - { - // ReSharper disable once AccessToDisposedClosure (will only be hit before the below disposal) - logger.Information("Received SIGINT/Ctrl-C, attempting graceful shutdown..."); - gracefulShutdownCts.Cancel(); - }; - - AppDomain.CurrentDomain.ProcessExit += (_, __) => - { - // This callback is fired on a SIGKILL is sent. - // The runtime will kill the program as soon as this callback is finished, so we have to - // block on the shutdown task's completion to ensure everything is sorted by the time this returns. + // Init the bot instance itself, register handlers and such to the client before beginning to connect + bot.Init(); - // ReSharper disable once AccessToDisposedClosure (it's only disposed after the block) - logger.Information("Received SIGKILL event, attempting graceful shutdown..."); - gracefulShutdownCts.Cancel(); - var ___ = shutdown.Task.Result; // Blocking! This is the only time it's justified... - }; + // Start the Discord shards themselves (handlers already set up) + logger.Information("Connecting to Discord"); + await StartCluster(services); + logger.Information("Connected! All is good (probably)."); + + // Lastly, we just... wait. Everything else is handled in the DiscordClient event loop try { - await taskFunc(gracefulShutdownCts.Token); - logger.Information("Shutdown complete. Have a nice day~"); + await Task.Delay(-1, ct); } - catch (Exception e) + catch (TaskCanceledException) { - logger.Fatal(e, "Error while running bot"); + // Once the CancellationToken fires, we need to shut stuff down + // (generally happens given a SIGINT/SIGKILL/Ctrl-C, see calling wrapper) + await bot.Shutdown(); } - - // Allow the log buffer to flush properly before exiting - ((Logger) logger).Dispose(); - await Task.Delay(500); - shutdown.SetResult(null); + }); + } + + private static async Task RunWrapper(IContainer services, Func taskFunc) + { + // This function does a couple things: + // - Creates a CancellationToken that'll cancel tasks once needed + // - Wraps the given function in an exception handler that properly logs errors + // - Adds a SIGINT (Ctrl-C) listener through Console.CancelKeyPress to gracefully shut down + // - Adds a SIGTERM (kill, systemctl stop, docker stop) listener through AppDomain.ProcessExit (same as above) + // todo: move run-clustered.sh to here + var logger = services.Resolve().ForContext(); + + var shutdown = new TaskCompletionSource(); + var gracefulShutdownCts = new CancellationTokenSource(); + + Console.CancelKeyPress += delegate + { + // ReSharper disable once AccessToDisposedClosure (will only be hit before the below disposal) + logger.Information("Received SIGINT/Ctrl-C, attempting graceful shutdown..."); + gracefulShutdownCts.Cancel(); + }; + + AppDomain.CurrentDomain.ProcessExit += (_, __) => + { + // This callback is fired on a SIGKILL is sent. + // The runtime will kill the program as soon as this callback is finished, so we have to + // block on the shutdown task's completion to ensure everything is sorted by the time this returns. + + // ReSharper disable once AccessToDisposedClosure (it's only disposed after the block) + logger.Information("Received SIGKILL event, attempting graceful shutdown..."); + gracefulShutdownCts.Cancel(); + var ___ = shutdown.Task.Result; // Blocking! This is the only time it's justified... + }; + + try + { + await taskFunc(gracefulShutdownCts.Token); + logger.Information("Shutdown complete. Have a nice day~"); + } + catch (Exception e) + { + logger.Fatal(e, "Error while running bot"); } - private static IContainer BuildContainer(IConfiguration config) + // Allow the log buffer to flush properly before exiting + ((Logger)logger).Dispose(); + await Task.Delay(500); + shutdown.SetResult(null); + } + + private static IContainer BuildContainer(IConfiguration config) + { + var builder = new ContainerBuilder(); + builder.RegisterInstance(config); + builder.RegisterModule(new ConfigModule("Bot")); + builder.RegisterModule(new LoggingModule("bot")); + builder.RegisterModule(new MetricsModule()); + builder.RegisterModule(); + builder.RegisterModule(); + return builder.Build(); + } + + private static async Task StartCluster(IComponentContext services) + { + var redis = services.Resolve(); + + var cluster = services.Resolve(); + var config = services.Resolve(); + + if (config.Cluster != null) { - var builder = new ContainerBuilder(); - builder.RegisterInstance(config); - builder.RegisterModule(new ConfigModule("Bot")); - builder.RegisterModule(new LoggingModule("bot", cfg => + var info = new GatewayInfo.Bot() { - cfg.Destructure.With(); - })); - builder.RegisterModule(new MetricsModule()); - builder.RegisterModule(); - builder.RegisterModule(); - return builder.Build(); + SessionStartLimit = new() + { + MaxConcurrency = config.MaxShardConcurrency ?? 1, + }, + Shards = config.Cluster.TotalShards, + Url = "wss://gateway.discord.gg", + }; + + // For multi-instance deployments, calculate the "span" of shards this node is responsible for + var totalNodes = config.Cluster.TotalNodes; + var totalShards = config.Cluster.TotalShards; + var nodeIndex = config.Cluster.NodeIndex; + + // Should evenly distribute shards even with an uneven amount of nodes + var shardMin = (int)Math.Round(totalShards * (float)nodeIndex / totalNodes); + var shardMax = (int)Math.Round(totalShards * (float)(nodeIndex + 1) / totalNodes) - 1; + + if (config.RedisGatewayUrl != null) + { + var shardService = services.Resolve(); + + for (var i = shardMin; i <= shardMax; i++) + await shardService.Start(i); + } + else + await cluster.Start(info.Url, shardMin, shardMax, totalShards, info.SessionStartLimit.MaxConcurrency, redis.Connection); + } + else + { + var info = await services.Resolve().GetGatewayBot(); + await cluster.Start(info, redis.Connection); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Interactive/BaseInteractive.cs b/PluralKit.Bot/Interactive/BaseInteractive.cs new file mode 100644 index 00000000..c2ae33ee --- /dev/null +++ b/PluralKit.Bot/Interactive/BaseInteractive.cs @@ -0,0 +1,124 @@ +using Autofac; + +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; + +using NodaTime; + +namespace PluralKit.Bot.Interactive; + +public abstract class BaseInteractive +{ + protected readonly List