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/Myriad/Builders/EmbedBuilder.cs b/Myriad/Builders/EmbedBuilder.cs index 00e6c3de..3f3e65f2 100644 --- a/Myriad/Builders/EmbedBuilder.cs +++ b/Myriad/Builders/EmbedBuilder.cs @@ -1,91 +1,73 @@ -using System.Collections.Generic; - using Myriad.Types; -namespace Myriad.Builders +namespace Myriad.Builders; + +public class EmbedBuilder { - public class EmbedBuilder + private readonly List _fields = new(); + private Embed _embed = new(); + + public EmbedBuilder Title(string? title) { - private Embed _embed = new(); - private readonly List _fields = 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() }; + _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 index 8e463914..762340de 100644 --- a/Myriad/Cache/DiscordCacheExtensions.cs +++ b/Myriad/Cache/DiscordCacheExtensions.cs @@ -1,125 +1,118 @@ -using System.Linq; -using System.Threading.Tasks; - using Myriad.Extensions; using Myriad.Gateway; using Myriad.Types; -namespace Myriad.Cache +namespace Myriad.Cache; + +public static class DiscordCacheExtensions { - public static class DiscordCacheExtensions + public static ValueTask HandleGatewayEvent(this IDiscordCache cache, IGatewayEvent evt) { - public static ValueTask HandleGatewayEvent(this IDiscordCache cache, IGatewayEvent evt) + switch (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; + 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); } - public static ValueTask TryUpdateSelfMember(this IDiscordCache cache, Shard shard, IGatewayEvent evt) - { - if (evt is GuildCreateEvent gc) - return cache.SaveSelfMember(gc.Id, gc.Members.FirstOrDefault(m => m.User.Id == shard.User?.Id)!); - if (evt is MessageCreateEvent mc && mc.Member != null && mc.Author.Id == shard.User?.Id) - return cache.SaveSelfMember(mc.GuildId!.Value, mc.Member); - if (evt is GuildMemberAddEvent gma && gma.User.Id == shard.User?.Id) - return cache.SaveSelfMember(gma.GuildId, gma); - if (evt is GuildMemberUpdateEvent gmu && gmu.User.Id == shard.User?.Id) - return cache.SaveSelfMember(gmu.GuildId, gmu); + return default; + } - 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 - return 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; + public static ValueTask TryUpdateSelfMember(this IDiscordCache cache, Shard shard, IGatewayEvent evt) + { + if (evt is GuildCreateEvent gc) + return cache.SaveSelfMember(gc.Id, gc.Members.FirstOrDefault(m => m.User.Id == shard.User?.Id)!); + if (evt is MessageCreateEvent mc && mc.Member != null && mc.Author.Id == shard.User?.Id) + return cache.SaveSelfMember(mc.GuildId!.Value, mc.Member); + if (evt is GuildMemberAddEvent gma && gma.User.Id == shard.User?.Id) + return cache.SaveSelfMember(gma.GuildId, gma); + if (evt is GuildMemberUpdateEvent gmu && gmu.User.Id == shard.User?.Id) + 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 index 1f94d1bc..7f522afc 100644 --- a/Myriad/Cache/IDiscordCache.cs +++ b/Myriad/Cache/IDiscordCache.cs @@ -1,34 +1,30 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - using Myriad.Types; -namespace Myriad.Cache +namespace Myriad.Cache; + +public interface IDiscordCache { - 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 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 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 TryGetDmChannel(ulong userId); - public Task TryGetUser(ulong userId); - public Task TryGetSelfMember(ulong guildId); - public Task TryGetRole(ulong roleId); + public Task GetOwnUser(); + public Task TryGetGuild(ulong guildId); + public Task TryGetChannel(ulong channelId); + public Task TryGetDmChannel(ulong userId); + public Task TryGetUser(ulong userId); + public Task TryGetSelfMember(ulong guildId); + public Task TryGetRole(ulong roleId); - public IAsyncEnumerable GetAllGuilds(); - public Task> GetGuildChannels(ulong guildId); - } + 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 index e1f27cb5..b715266e 100644 --- a/Myriad/Cache/MemoryDiscordCache.cs +++ b/Myriad/Cache/MemoryDiscordCache.cs @@ -1,206 +1,190 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Myriad.Types; -namespace Myriad.Cache +namespace Myriad.Cache; + +public class MemoryDiscordCache: IDiscordCache { - public class MemoryDiscordCache: IDiscordCache + private readonly ConcurrentDictionary _channels = new(); + private readonly ConcurrentDictionary _dmChannels = 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) { - private readonly ConcurrentDictionary _channels = new(); - private readonly ConcurrentDictionary _dmChannels = new(); - private readonly ConcurrentDictionary _guilds = new(); - private readonly ConcurrentDictionary _roles = new(); - private readonly ConcurrentDictionary _users = new(); - private readonly ConcurrentDictionary _guildMembers = new(); - private ulong? _ownUserId { get; set; } + SaveGuildRaw(guild); - public ValueTask SaveGuild(Guild guild) - { - SaveGuildRaw(guild); - - 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) - { - _dmChannels[recipient.Id] = channel.Id; - 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) - { + 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; - if (_guilds.TryGetValue(guildId, out var guild)) + 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) { - // 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; + _dmChannels[recipient.Id] = channel.Id; + await SaveUser(recipient); + } + } - guild.Guild.Roles[i] = role; - found = true; - } + public ValueTask SaveOwnUser(ulong userId) + { + // this (hopefully) never changes at runtime, so we skip out on re-assigning it + if (_ownUserId == null) + _ownUserId = userId; - if (!found) - { - _guilds[guildId] = guild with - { - Guild = guild.Guild with - { - Roles = guild.Guild.Roles.Concat(new[] { role }).ToArray() - } - }; - } + 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; - } - 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; - } + if (channel.GuildId != null && _guilds.TryGetValue(channel.GuildId.Value, out var guild)) + guild.Channels.TryRemove(channel.Id, out _); - public ValueTask RemoveGuild(ulong guildId) - { - _guilds.TryRemove(guildId, out _); - return default; - } + return default; + } - public ValueTask RemoveChannel(ulong channelId) - { - if (!_channels.TryRemove(channelId, out var channel)) - return default; + public ValueTask RemoveUser(ulong userId) + { + _users.TryRemove(userId, out _); + return default; + } - if (channel.GuildId != null && _guilds.TryGetValue(channel.GuildId.Value, out var guild)) - guild.Channels.TryRemove(channel.Id, out _); + public Task GetOwnUser() => Task.FromResult(_ownUserId!.Value); - return default; - } + public ValueTask RemoveRole(ulong guildId, ulong roleId) + { + _roles.TryRemove(roleId, out _); + return default; + } - public ValueTask RemoveUser(ulong userId) - { - _users.TryRemove(userId, out _); - return default; - } + public Task TryGetGuild(ulong guildId) + { + _guilds.TryGetValue(guildId, out var cg); + return Task.FromResult(cg?.Guild); + } - public Task GetOwnUser() => Task.FromResult(_ownUserId!.Value); + public Task TryGetChannel(ulong channelId) + { + _channels.TryGetValue(channelId, out var channel); + return Task.FromResult(channel); + } - public ValueTask RemoveRole(ulong guildId, ulong roleId) - { - _roles.TryRemove(roleId, out _); - return default; - } + public Task TryGetDmChannel(ulong userId) + { + if (!_dmChannels.TryGetValue(userId, out var channelId)) + return Task.FromResult((Channel?)null); + return TryGetChannel(channelId); + } - public Task TryGetGuild(ulong guildId) - { - _guilds.TryGetValue(guildId, out var cg); - return Task.FromResult(cg?.Guild); - } + public Task TryGetUser(ulong userId) + { + _users.TryGetValue(userId, out var user); + return Task.FromResult(user); + } - public Task TryGetChannel(ulong channelId) - { - _channels.TryGetValue(channelId, out var channel); - return Task.FromResult(channel); - } + public Task TryGetSelfMember(ulong guildId) + { + _guildMembers.TryGetValue(guildId, out var guildMember); + return Task.FromResult(guildMember); + } - public Task TryGetDmChannel(ulong userId) - { - if (!_dmChannels.TryGetValue(userId, out var channelId)) - return Task.FromResult((Channel?)null); - return TryGetChannel(channelId); - } + public Task TryGetRole(ulong roleId) + { + _roles.TryGetValue(roleId, out var role); + return Task.FromResult(role); + } - public Task TryGetUser(ulong userId) - { - _users.TryGetValue(userId, out var user); - return Task.FromResult(user); - } + public IAsyncEnumerable GetAllGuilds() + { + return _guilds.Values + .Select(g => g.Guild) + .ToAsyncEnumerable(); + } - public Task TryGetSelfMember(ulong guildId) - { - _guildMembers.TryGetValue(guildId, out var guildMember); - return Task.FromResult(guildMember); - } + public Task> GetGuildChannels(ulong guildId) + { + if (!_guilds.TryGetValue(guildId, out var guild)) + throw new ArgumentException("Guild not found", nameof(guildId)); - public Task TryGetRole(ulong roleId) - { - _roles.TryGetValue(roleId, out var role); - return Task.FromResult(role); - } + return Task.FromResult(guild.Channels.Keys.Select(c => _channels[c])); + } - public IAsyncEnumerable GetAllGuilds() - { - return _guilds.Values - .Select(g => g.Guild) - .ToAsyncEnumerable(); - } + private CachedGuild SaveGuildRaw(Guild guild) => + _guilds.GetOrAdd(guild.Id, (_, g) => new CachedGuild(g), guild); - 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 CachedGuild SaveGuildRaw(Guild guild) => - _guilds.GetOrAdd(guild.Id, (_, g) => new CachedGuild(g), guild); - - private record CachedGuild(Guild Guild) - { - public readonly ConcurrentDictionary Channels = new(); - } + private record CachedGuild(Guild Guild) + { + public readonly ConcurrentDictionary Channels = new(); } } \ No newline at end of file diff --git a/Myriad/Extensions/CacheExtensions.cs b/Myriad/Extensions/CacheExtensions.cs index d074e987..8e491031 100644 --- a/Myriad/Extensions/CacheExtensions.cs +++ b/Myriad/Extensions/CacheExtensions.cs @@ -1,82 +1,81 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - using Myriad.Cache; using Myriad.Rest; using Myriad.Types; -namespace Myriad.Extensions +namespace Myriad.Extensions; + +public static class CacheExtensions { - public static class CacheExtensions + public static async Task GetGuild(this IDiscordCache cache, ulong guildId) { - 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; - } + 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 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 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 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; + 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; - } + 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; + 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 GetOrCreateDmChannel(this IDiscordCache cache, DiscordApiClient rest, ulong recipientId) - { - if (await cache.TryGetDmChannel(recipientId) is { } cacheChannel) - return cacheChannel; - - var restChannel = await rest.CreateDm(recipientId); + var restChannel = await rest.GetChannel(channelId); + if (restChannel != null) await cache.SaveChannel(restChannel); - return restChannel; - } + return restChannel; + } - public static async Task GetRootChannel(this IDiscordCache cache, ulong channelOrThread) - { - var channel = await cache.GetChannel(channelOrThread); - if (!channel.IsThread()) - return channel; + public static async Task GetOrCreateDmChannel(this IDiscordCache cache, DiscordApiClient rest, + ulong recipientId) + { + if (await cache.TryGetDmChannel(recipientId) is { } cacheChannel) + return cacheChannel; - var parent = await cache.GetChannel(channel.ParentId!.Value); - return parent; - } + var restChannel = await rest.CreateDm(recipientId); + 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 index 0af2323c..1512f32e 100644 --- a/Myriad/Extensions/ChannelExtensions.cs +++ b/Myriad/Extensions/ChannelExtensions.cs @@ -1,16 +1,15 @@ using Myriad.Types; -namespace Myriad.Extensions +namespace Myriad.Extensions; + +public static class ChannelExtensions { - public static class ChannelExtensions - { - public static string Mention(this Channel channel) => $"<#{channel.Id}>"; + 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 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; - } + 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 index 3e9cc674..151e6e50 100644 --- a/Myriad/Extensions/GuildExtensions.cs +++ b/Myriad/Extensions/GuildExtensions.cs @@ -1,22 +1,21 @@ using Myriad.Types; -namespace Myriad.Extensions +namespace Myriad.Extensions; + +public static class GuildExtensions { - public static class GuildExtensions + public static int FileSizeLimit(this Guild guild) { - public static int FileSizeLimit(this Guild guild) + switch (guild.PremiumTier) { - 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; - } + 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 index 8faac26e..93498484 100644 --- a/Myriad/Extensions/MessageExtensions.cs +++ b/Myriad/Extensions/MessageExtensions.cs @@ -1,14 +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}"; +namespace Myriad.Extensions; - public static string JumpLink(this MessageReactionAddEvent msg) => - $"https://discord.com/channels/{msg.GuildId}/{msg.ChannelId}/{msg.MessageId}"; - } +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 index 3d326f25..b8064520 100644 --- a/Myriad/Extensions/PermissionExtensions.cs +++ b/Myriad/Extensions/PermissionExtensions.cs @@ -1,176 +1,167 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - using Myriad.Cache; using Myriad.Gateway; using Myriad.Types; -namespace Myriad.Extensions +namespace Myriad.Extensions; + +public static class PermissionExtensions { - 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) { - public static Task PermissionsFor(this IDiscordCache cache, MessageCreateEvent message) => - PermissionsFor(cache, message.ChannelId, message.Author.Id, message.Member, isWebhook: message.WebhookId != null); + if (!(await cache.TryGetChannel(channelId) is Channel channel)) + // todo: handle channel not found better + return PermissionSet.Dm; - public static Task PermissionsFor(this IDiscordCache cache, ulong channelId, GuildMember member) => - PermissionsFor(cache, channelId, member.User.Id, member); + if (channel.GuildId == null) + return PermissionSet.Dm; - 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; + var rootChannel = await cache.GetRootChannel(channelId); - if (channel.GuildId == null) - return PermissionSet.Dm; + var guild = await cache.GetGuild(channel.GuildId.Value); - var rootChannel = await cache.GetRootChannel(channelId); + if (isWebhook) + return EveryonePermissions(guild); - 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 ?? PermissionSet.Dm; - - public static async Task EveryonePermissions(this IDiscordCache cache, Channel channel) - { - if (channel.Type == Channel.ChannelType.Dm) - return PermissionSet.Dm; - - var defaultPermissions = (await cache.GetGuild(channel.GuildId!.Value)).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; - } - - 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 string ToPermissionString(this PermissionSet perms) - { - // TODO: clean string - return perms.ToString(); - } + return PermissionsFor(guild, rootChannel, userId, member); } + + public static PermissionSet EveryonePermissions(this Guild guild) => + guild.Roles.FirstOrDefault(r => r.Id == guild.Id)?.Permissions ?? PermissionSet.Dm; + + public static async Task EveryonePermissions(this IDiscordCache cache, Channel channel) + { + if (channel.Type == Channel.ChannelType.Dm) + return PermissionSet.Dm; + + var defaultPermissions = (await cache.GetGuild(channel.GuildId!.Value)).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 index 57f00211..f5cc3a2a 100644 --- a/Myriad/Extensions/SnowflakeExtensions.cs +++ b/Myriad/Extensions/SnowflakeExtensions.cs @@ -1,20 +1,17 @@ -using System; - using Myriad.Types; -namespace Myriad.Extensions +namespace Myriad.Extensions; + +public static class SnowflakeExtensions { - public static class SnowflakeExtensions - { - public static readonly DateTimeOffset DiscordEpoch = new(2015, 1, 1, 0, 0, 0, TimeSpan.Zero); + 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 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); - } + 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 index 4a5d7ec6..f908958e 100644 --- a/Myriad/Extensions/UserExtensions.cs +++ b/Myriad/Extensions/UserExtensions.cs @@ -1,12 +1,11 @@ using Myriad.Types; -namespace Myriad.Extensions -{ - public static class UserExtensions - { - public static string Mention(this User user) => $"<@{user.Id}>"; +namespace Myriad.Extensions; - 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}"; - } +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 index 709c7ab3..8575b123 100644 --- a/Myriad/Gateway/Cluster.cs +++ b/Myriad/Gateway/Cluster.cs @@ -1,90 +1,84 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Myriad.Gateway.Limit; using Myriad.Types; using Serilog; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public class Cluster { - public class Cluster + private readonly GatewaySettings _gatewaySettings; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _shards = new(); + private IGatewayRatelimiter? _ratelimiter; + + public Cluster(GatewaySettings gatewaySettings, ILogger logger) { - private readonly GatewaySettings _gatewaySettings; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _shards = new(); - private IGatewayRatelimiter? _ratelimiter; + _gatewaySettings = gatewaySettings; + _logger = logger.ForContext(); + } - public Cluster(GatewaySettings gatewaySettings, ILogger logger) - { - _gatewaySettings = gatewaySettings; - _logger = logger.ForContext(); - } + public Func? EventReceived { get; set; } - public Func? EventReceived { get; set; } - public event Action? ShardCreated; + public IReadOnlyDictionary Shards => _shards; + public event Action? ShardCreated; - public IReadOnlyDictionary Shards => _shards; + public async Task Start(GatewayInfo.Bot info) + { + await Start(info.Url, 0, info.Shards - 1, info.Shards, info.SessionStartLimit.MaxConcurrency); + } - public async Task Start(GatewayInfo.Bot info) - { - await Start(info.Url, 0, info.Shards - 1, info.Shards, info.SessionStartLimit.MaxConcurrency); - } + public async Task Start(string url, int shardMin, int shardMax, int shardTotal, int recommendedConcurrency) + { + _ratelimiter = GetRateLimiter(recommendedConcurrency); - public async Task Start(string url, int shardMin, int shardMax, int shardTotal, int recommendedConcurrency) - { - _ratelimiter = GetRateLimiter(recommendedConcurrency); + 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)); - 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(); + } - await StartShards(); - } - private async Task StartShards() - { - _logger.Information("Connecting shards..."); - foreach (var shard in _shards.Values) - await shard.Start(); - } + 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); - shard.OnEventReceived += evt => OnShardEventReceived(shard, evt); - _shards[shardInfo.ShardId] = shard; + private void CreateAndAddShard(string url, ShardInfo shardInfo) + { + var shard = new Shard(_gatewaySettings, shardInfo, _ratelimiter!, url, _logger); + shard.OnEventReceived += evt => OnShardEventReceived(shard, evt); + _shards[shardInfo.ShardId] = shard; - ShardCreated?.Invoke(shard); - } + ShardCreated?.Invoke(shard); + } - private async Task OnShardEventReceived(Shard shard, IGatewayEvent evt) - { - if (EventReceived != null) - await EventReceived(shard, evt); - } + 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; + private int GetActualShardConcurrency(int recommendedConcurrency) + { + if (_gatewaySettings.MaxShardConcurrency == null) + return recommendedConcurrency; - return Math.Min(_gatewaySettings.MaxShardConcurrency.Value, recommendedConcurrency); - } + return Math.Min(_gatewaySettings.MaxShardConcurrency.Value, recommendedConcurrency); + } - private IGatewayRatelimiter GetRateLimiter(int recommendedConcurrency) - { - if (_gatewaySettings.GatewayQueueUrl != null) - { - return new TwilightGatewayRatelimiter(_logger, _gatewaySettings.GatewayQueueUrl); - } + private IGatewayRatelimiter GetRateLimiter(int recommendedConcurrency) + { + if (_gatewaySettings.GatewayQueueUrl != null) + return new TwilightGatewayRatelimiter(_logger, _gatewaySettings.GatewayQueueUrl); - var concurrency = GetActualShardConcurrency(recommendedConcurrency); - return new LocalGatewayRatelimiter(_logger, concurrency); - } + var concurrency = GetActualShardConcurrency(recommendedConcurrency); + 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 index df4186af..b8b8ab30 100644 --- a/Myriad/Gateway/Events/ChannelCreateEvent.cs +++ b/Myriad/Gateway/Events/ChannelCreateEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record ChannelCreateEvent: Channel, IGatewayEvent; -} \ No newline at end of file +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 index b30ec0cf..9dec3e6a 100644 --- a/Myriad/Gateway/Events/ChannelDeleteEvent.cs +++ b/Myriad/Gateway/Events/ChannelDeleteEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record ChannelDeleteEvent: Channel, IGatewayEvent; -} \ No newline at end of file +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 index 1230b5b1..dd219cef 100644 --- a/Myriad/Gateway/Events/ChannelUpdateEvent.cs +++ b/Myriad/Gateway/Events/ChannelUpdateEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record ChannelUpdateEvent: Channel, IGatewayEvent; -} \ No newline at end of file +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 index 1d211be2..f2d96f55 100644 --- a/Myriad/Gateway/Events/GuildCreateEvent.cs +++ b/Myriad/Gateway/Events/GuildCreateEvent.cs @@ -1,11 +1,10 @@ using Myriad.Types; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record GuildCreateEvent: Guild, IGatewayEvent { - public record GuildCreateEvent: Guild, IGatewayEvent - { - public Channel[] Channels { get; init; } - public GuildMember[] Members { get; init; } - public Channel[] Threads { get; init; } - } + 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 index 8e52097c..44e90ff9 100644 --- a/Myriad/Gateway/Events/GuildDeleteEvent.cs +++ b/Myriad/Gateway/Events/GuildDeleteEvent.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record GuildDeleteEvent(ulong Id, bool Unavailable): IGatewayEvent; -} \ No newline at end of file +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 index a4f2fe7f..489b90e5 100644 --- a/Myriad/Gateway/Events/GuildMemberAddEvent.cs +++ b/Myriad/Gateway/Events/GuildMemberAddEvent.cs @@ -1,9 +1,8 @@ using Myriad.Types; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record GuildMemberAddEvent: GuildMember, IGatewayEvent { - public record GuildMemberAddEvent: GuildMember, IGatewayEvent - { - public ulong GuildId { get; init; } - } + 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 index a0506872..5b57635c 100644 --- a/Myriad/Gateway/Events/GuildMemberRemoveEvent.cs +++ b/Myriad/Gateway/Events/GuildMemberRemoveEvent.cs @@ -1,10 +1,9 @@ using Myriad.Types; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public class GuildMemberRemoveEvent: IGatewayEvent { - public class GuildMemberRemoveEvent: IGatewayEvent - { - public ulong GuildId { get; init; } - public User User { get; init; } - } + 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 index aac1cf7f..c9c2620e 100644 --- a/Myriad/Gateway/Events/GuildMemberUpdateEvent.cs +++ b/Myriad/Gateway/Events/GuildMemberUpdateEvent.cs @@ -1,9 +1,8 @@ using Myriad.Types; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record GuildMemberUpdateEvent: GuildMember, IGatewayEvent { - public record GuildMemberUpdateEvent: GuildMember, IGatewayEvent - { - public ulong GuildId { get; init; } - } + 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 index fddb0a30..bd9272e8 100644 --- a/Myriad/Gateway/Events/GuildRoleCreateEvent.cs +++ b/Myriad/Gateway/Events/GuildRoleCreateEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record GuildRoleCreateEvent(ulong GuildId, Role Role): IGatewayEvent; -} \ No newline at end of file +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 index 860eebda..79aa3ade 100644 --- a/Myriad/Gateway/Events/GuildRoleDeleteEvent.cs +++ b/Myriad/Gateway/Events/GuildRoleDeleteEvent.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record GuildRoleDeleteEvent(ulong GuildId, ulong RoleId): IGatewayEvent; -} \ No newline at end of file +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 index 0f17cf12..7d028e50 100644 --- a/Myriad/Gateway/Events/GuildRoleUpdateEvent.cs +++ b/Myriad/Gateway/Events/GuildRoleUpdateEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record GuildRoleUpdateEvent(ulong GuildId, Role Role): IGatewayEvent; -} \ No newline at end of file +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 index 1da14092..9d639992 100644 --- a/Myriad/Gateway/Events/GuildUpdateEvent.cs +++ b/Myriad/Gateway/Events/GuildUpdateEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record GuildUpdateEvent: Guild, IGatewayEvent; -} \ No newline at end of file +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 index a4ae61ad..2593c6a5 100644 --- a/Myriad/Gateway/Events/IGatewayEvent.cs +++ b/Myriad/Gateway/Events/IGatewayEvent.cs @@ -1,39 +1,35 @@ -using System; -using System.Collections.Generic; +namespace Myriad.Gateway; -namespace Myriad.Gateway +public interface IGatewayEvent { - public interface IGatewayEvent + public static readonly Dictionary EventTypes = new() { - 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) } - }; - } + { "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 index 05156d9f..350e1883 100644 --- a/Myriad/Gateway/Events/InteractionCreateEvent.cs +++ b/Myriad/Gateway/Events/InteractionCreateEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record InteractionCreateEvent: Interaction, IGatewayEvent; -} \ No newline at end of file +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 index 02a88712..265684a7 100644 --- a/Myriad/Gateway/Events/MessageCreateEvent.cs +++ b/Myriad/Gateway/Events/MessageCreateEvent.cs @@ -1,9 +1,8 @@ using Myriad.Types; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record MessageCreateEvent: Message, IGatewayEvent { - public record MessageCreateEvent: Message, IGatewayEvent - { - public GuildMemberPartial? Member { get; init; } - } + 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 index 96268129..1f99a487 100644 --- a/Myriad/Gateway/Events/MessageDeleteBulkEvent.cs +++ b/Myriad/Gateway/Events/MessageDeleteBulkEvent.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record MessageDeleteBulkEvent(ulong[] Ids, ulong ChannelId, ulong? GuildId): IGatewayEvent; -} \ No newline at end of file +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 index a5f533d5..511fbdbc 100644 --- a/Myriad/Gateway/Events/MessageDeleteEvent.cs +++ b/Myriad/Gateway/Events/MessageDeleteEvent.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record MessageDeleteEvent(ulong Id, ulong ChannelId, ulong? GuildId): IGatewayEvent; -} \ No newline at end of file +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 index da05fc52..d9a26f9e 100644 --- a/Myriad/Gateway/Events/MessageReactionAddEvent.cs +++ b/Myriad/Gateway/Events/MessageReactionAddEvent.cs @@ -1,8 +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 +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 index 972537b1..55d94247 100644 --- a/Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs +++ b/Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record MessageReactionRemoveAllEvent(ulong ChannelId, ulong MessageId, ulong? GuildId): IGatewayEvent; -} \ No newline at end of file +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 index 1bad7f44..861c3a56 100644 --- a/Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs +++ b/Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs @@ -1,7 +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 +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 index 37b186d8..a5e9b043 100644 --- a/Myriad/Gateway/Events/MessageReactionRemoveEvent.cs +++ b/Myriad/Gateway/Events/MessageReactionRemoveEvent.cs @@ -1,7 +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 +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 index 2123ede3..2e918fb8 100644 --- a/Myriad/Gateway/Events/MessageUpdateEvent.cs +++ b/Myriad/Gateway/Events/MessageUpdateEvent.cs @@ -1,15 +1,15 @@ using Myriad.Types; using Myriad.Utils; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record MessageUpdateEvent(ulong Id, ulong ChannelId): IGatewayEvent { - 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 - } + 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 index cb0c2dbd..781962a2 100644 --- a/Myriad/Gateway/Events/ReadyEvent.cs +++ b/Myriad/Gateway/Events/ReadyEvent.cs @@ -2,14 +2,13 @@ using System.Text.Json.Serialization; using Myriad.Types; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record ReadyEvent: IGatewayEvent { - 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; } - } + [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 index 2cb216a9..b6264380 100644 --- a/Myriad/Gateway/Events/ResumedEvent.cs +++ b/Myriad/Gateway/Events/ResumedEvent.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record ResumedEvent: IGatewayEvent; -} \ No newline at end of file +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 index 832e3fc1..4a89c765 100644 --- a/Myriad/Gateway/Events/ThreadCreateEvent.cs +++ b/Myriad/Gateway/Events/ThreadCreateEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record ThreadCreateEvent: Channel, IGatewayEvent; -} \ No newline at end of file +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 index 09fe2fda..4b2dd0be 100644 --- a/Myriad/Gateway/Events/ThreadDeleteEvent.cs +++ b/Myriad/Gateway/Events/ThreadDeleteEvent.cs @@ -1,12 +1,11 @@ using Myriad.Types; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record ThreadDeleteEvent: IGatewayEvent { - 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; } - } + 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 index 07ec3356..989e7175 100644 --- a/Myriad/Gateway/Events/ThreadListSyncEvent.cs +++ b/Myriad/Gateway/Events/ThreadListSyncEvent.cs @@ -1,11 +1,10 @@ using Myriad.Types; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record ThreadListSyncEvent: IGatewayEvent { - public record ThreadListSyncEvent: IGatewayEvent - { - public ulong GuildId { get; init; } - public ulong[]? ChannelIds { get; init; } - public Channel[] Threads { get; init; } - } + 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 index 6f8f6c7a..81a7d2b3 100644 --- a/Myriad/Gateway/Events/ThreadUpdateEvent.cs +++ b/Myriad/Gateway/Events/ThreadUpdateEvent.cs @@ -1,6 +1,5 @@ using Myriad.Types; -namespace Myriad.Gateway -{ - public record ThreadUpdateEvent: Channel, IGatewayEvent; -} \ No newline at end of file +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 index d6be8a2b..4a4d2276 100644 --- a/Myriad/Gateway/GatewayCloseException.cs +++ b/Myriad/Gateway/GatewayCloseException.cs @@ -1,35 +1,32 @@ -using System; +namespace Myriad.Gateway; -namespace Myriad.Gateway +// TODO: unused? +public class GatewayCloseException: Exception { - // TODO: unused? - public class GatewayCloseException: Exception + public GatewayCloseException(int closeCode, string closeReason) : base($"{closeCode}: {closeReason}") { - public GatewayCloseException(int closeCode, string closeReason) : base($"{closeCode}: {closeReason}") - { - CloseCode = closeCode; - CloseReason = closeReason; - } - - public int CloseCode { get; } - public string CloseReason { get; } + CloseCode = closeCode; + CloseReason = closeReason; } - 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; - } + 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 index fc1a1cda..ca0c8e66 100644 --- a/Myriad/Gateway/GatewayIntent.cs +++ b/Myriad/Gateway/GatewayIntent.cs @@ -1,24 +1,21 @@ -using System; +namespace Myriad.Gateway; -namespace Myriad.Gateway +[Flags] +public enum GatewayIntent { - [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 - } + 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 } \ No newline at end of file diff --git a/Myriad/Gateway/GatewayPacket.cs b/Myriad/Gateway/GatewayPacket.cs index 50a2b502..7c8628ff 100644 --- a/Myriad/Gateway/GatewayPacket.cs +++ b/Myriad/Gateway/GatewayPacket.cs @@ -1,33 +1,32 @@ using System.Text.Json.Serialization; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record GatewayPacket { - public record GatewayPacket - { - [JsonPropertyName("op")] public GatewayOpcode Opcode { get; init; } - [JsonPropertyName("d")] public object? Payload { get; init; } + [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("s")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Sequence { get; init; } - [JsonPropertyName("t")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? EventType { 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 - } +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 index 4690a4ab..f29e2321 100644 --- a/Myriad/Gateway/GatewaySettings.cs +++ b/Myriad/Gateway/GatewaySettings.cs @@ -1,10 +1,9 @@ -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record GatewaySettings { - public record GatewaySettings - { - public string Token { get; init; } - public GatewayIntent Intents { get; init; } - public int? MaxShardConcurrency { get; init; } - public string? GatewayQueueUrl { get; init; } - } + public string Token { get; init; } + public GatewayIntent Intents { get; init; } + 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 index 7b8096b7..2dbb79ee 100644 --- a/Myriad/Gateway/Limit/IGatewayRatelimiter.cs +++ b/Myriad/Gateway/Limit/IGatewayRatelimiter.cs @@ -1,9 +1,6 @@ -using System.Threading.Tasks; +namespace Myriad.Gateway.Limit; -namespace Myriad.Gateway.Limit +public interface IGatewayRatelimiter { - public interface IGatewayRatelimiter - { - public Task Identify(int shard); - } + 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 index c85e4251..7042aa4a 100644 --- a/Myriad/Gateway/Limit/LocalGatewayRatelimiter.cs +++ b/Myriad/Gateway/Limit/LocalGatewayRatelimiter.cs @@ -1,73 +1,70 @@ -using System; using System.Collections.Concurrent; -using System.Threading.Tasks; using Serilog; -namespace Myriad.Gateway.Limit +namespace Myriad.Gateway.Limit; + +public class LocalGatewayRatelimiter: IGatewayRatelimiter { - 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) { - // 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); + _logger = logger.ForContext(); + _maxConcurrency = maxConcurrency; + } - private readonly ConcurrentDictionary> _buckets = new(); - private readonly int _maxConcurrency; + public Task Identify(int shard) + { + var bucket = shard % _maxConcurrency; + var queue = _buckets.GetOrAdd(bucket, _ => new ConcurrentQueue()); + var tcs = new TaskCompletionSource(); + queue.Enqueue(tcs); - private Task? _refillTask; - private readonly ILogger _logger; + ScheduleRefill(); - public LocalGatewayRatelimiter(ILogger logger, int maxConcurrency) + 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) { - _logger = logger.ForContext(); - _maxConcurrency = maxConcurrency; - } + var isClear = true; + foreach (var (bucket, queue) in _buckets) + { + if (!queue.TryDequeue(out var tcs)) + continue; - public Task Identify(int shard) - { - var bucket = shard % _maxConcurrency; - var queue = _buckets.GetOrAdd(bucket, _ => new ConcurrentQueue()); - var tcs = new TaskCompletionSource(); - queue.Enqueue(tcs); + _logger.Debug( + "Allowing identify for bucket {BucketId} through ({QueueLength} left in bucket queue)", + bucket, queue.Count); + tcs.SetResult(); + isClear = false; + } - ScheduleRefill(); - - return tcs.Task; - } - - private void ScheduleRefill() - { - if (_refillTask != null && !_refillTask.IsCompleted) + if (isClear) 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); - } + await Task.Delay(BucketLength); } } } \ No newline at end of file diff --git a/Myriad/Gateway/Limit/TwilightGatewayRatelimiter.cs b/Myriad/Gateway/Limit/TwilightGatewayRatelimiter.cs index 8707751d..a52e6754 100644 --- a/Myriad/Gateway/Limit/TwilightGatewayRatelimiter.cs +++ b/Myriad/Gateway/Limit/TwilightGatewayRatelimiter.cs @@ -1,41 +1,30 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; - using Serilog; -namespace Myriad.Gateway.Limit +namespace Myriad.Gateway.Limit; + +public class TwilightGatewayRatelimiter: IGatewayRatelimiter { - public class TwilightGatewayRatelimiter: IGatewayRatelimiter + private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; + + private readonly ILogger _logger; + private readonly string _url; + + public TwilightGatewayRatelimiter(ILogger logger, string url) { - private readonly string _url; - private readonly ILogger _logger; - private readonly HttpClient _httpClient = new() - { - Timeout = TimeSpan.FromSeconds(60) - }; + _url = url; + _logger = logger.ForContext(); + } - public TwilightGatewayRatelimiter(ILogger logger, string url) - { - _url = url; - _logger = logger.ForContext(); - } - - public async Task Identify(int shard) - { - while (true) + public async Task Identify(int shard) + { + while (true) + try { - try - { - _logger.Information("Shard {ShardId}: Requesting identify at gateway queue {GatewayQueueUrl}", - shard, _url); - await _httpClient.GetAsync(_url); - return; - } - catch (TimeoutException) - { - } + _logger.Information("Shard {ShardId}: Requesting identify at gateway queue {GatewayQueueUrl}", + shard, _url); + await _httpClient.GetAsync(_url); + return; } - } + catch (TimeoutException) { } } } \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayHello.cs b/Myriad/Gateway/Payloads/GatewayHello.cs index 775a9b31..db394d53 100644 --- a/Myriad/Gateway/Payloads/GatewayHello.cs +++ b/Myriad/Gateway/Payloads/GatewayHello.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record GatewayHello(int HeartbeatInterval); -} \ No newline at end of file +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 index 2357f533..a6d1e683 100644 --- a/Myriad/Gateway/Payloads/GatewayIdentify.cs +++ b/Myriad/Gateway/Payloads/GatewayIdentify.cs @@ -1,28 +1,27 @@ using System.Text.Json.Serialization; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public record GatewayIdentify { - 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; } + + public record ConnectionProperties { - 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; } - - public record ConnectionProperties - { - [JsonPropertyName("$os")] public string Os { get; init; } - [JsonPropertyName("$browser")] public string Browser { get; init; } - [JsonPropertyName("$device")] public string Device { get; init; } - } + [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 index 83d561a1..782c61cf 100644 --- a/Myriad/Gateway/Payloads/GatewayResume.cs +++ b/Myriad/Gateway/Payloads/GatewayResume.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record GatewayResume(string Token, string SessionId, int Seq); -} \ No newline at end of file +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 index 55f7e8ef..89892db7 100644 --- a/Myriad/Gateway/Payloads/GatewayStatusUpdate.cs +++ b/Myriad/Gateway/Payloads/GatewayStatusUpdate.cs @@ -3,23 +3,22 @@ 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 - } +namespace Myriad.Gateway; - public ulong? Since { get; init; } - public ActivityPartial[]? Activities { get; init; } - public UserStatus Status { get; init; } - public bool Afk { get; init; } +public record GatewayStatusUpdate +{ + [JsonConverter(typeof(JsonSnakeCaseStringEnumConverter))] + public enum UserStatus + { + Online, + Dnd, + Idle, + Invisible, + Offline } + + public ulong? Since { get; init; } + public ActivityPartial[]? 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 index b2c72442..f52c82de 100644 --- a/Myriad/Gateway/Shard.cs +++ b/Myriad/Gateway/Shard.cs @@ -1,7 +1,5 @@ -using System; using System.Net.WebSockets; using System.Text.Json; -using System.Threading.Tasks; using Myriad.Gateway.Limit; using Myriad.Gateway.State; @@ -11,214 +9,203 @@ using Myriad.Types; using Serilog; using Serilog.Context; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public class Shard { - 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; + + public Shard(GatewaySettings settings, ShardInfo info, IGatewayRatelimiter ratelimiter, string url, ILogger logger) { - private const string LibraryName = "Myriad (for PluralKit)"; + _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad(); - 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; - - public Shard(GatewaySettings settings, ShardInfo info, IGatewayRatelimiter ratelimiter, string url, ILogger logger) + _settings = settings; + _info = info; + _ratelimiter = ratelimiter; + _url = url; + _logger = logger.ForContext().ForContext("ShardId", info.ShardId); + _stateManager = new ShardStateManager(info, _jsonSerializerOptions, logger) { - _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad(); - - _settings = settings; - _info = info; - _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); - } - - private async Task ShardLoop() + HandleEvent = HandleEvent, + SendHeartbeat = SendHeartbeat, + SendIdentify = SendIdentify, + SendResume = SendResume, + Connect = ConnectInner, + Reconnect = Reconnect, + }; + _stateManager.OnHeartbeatReceived += latency => { - // may be superfluous but this adds shard id to ambient context which is nice - using var _ = LogContext.PushProperty("ShardId", _info.ShardId); + HeartbeatReceived?.Invoke(latency); + }; - while (true) + _conn = new ShardConnection(_jsonSerializerOptions, _logger); + } + + 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 { - try + await ConnectInner(); + + await HandleConnectionOpened(); + + while (_conn.State == WebSocketState.Open) { - await ConnectInner(); + var packet = await _conn.Read(); + if (packet == null) + break; - 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; + await _stateManager.HandlePacketReceived(packet); } - 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)); - } + 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; } - } - - 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 + catch (Exception e) { - Opcode = GatewayOpcode.PresenceUpdate, - Payload = payload - }); - } + _logger.Error(e, "Shard {ShardId}: Error in main shard loop, reconnecting in 5 seconds...", _info.ShardId); - 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)); - } + // todo: exponential backoff here? this should never happen, ideally... + await Task.Delay(TimeSpan.FromSeconds(5)); } } - - private async Task DisconnectInner(WebSocketCloseStatus closeStatus) - { - await _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 - } - }); - } - - 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); - } + } + + 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 + } + }); + + 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 index 23733fcc..5ae4e7e1 100644 --- a/Myriad/Gateway/ShardConnection.cs +++ b/Myriad/Gateway/ShardConnection.cs @@ -1,122 +1,115 @@ -using System; using System.Net.WebSockets; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using Serilog; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public class ShardConnection: IAsyncDisposable { - public class ShardConnection: IAsyncDisposable + private readonly ILogger _logger; + private readonly ShardPacketSerializer _serializer; + private ClientWebSocket? _client; + + public ShardConnection(JsonSerializerOptions jsonSerializerOptions, ILogger logger) { - private ClientWebSocket? _client; - private readonly ILogger _logger; - private readonly ShardPacketSerializer _serializer; + _logger = logger.ForContext(); + _serializer = new ShardPacketSerializer(jsonSerializerOptions); + } - public WebSocketState State => _client?.State ?? WebSocketState.Closed; - public WebSocketCloseStatus? CloseStatus => _client?.CloseStatus; - public string? CloseStatusDescription => _client?.CloseStatusDescription; + public WebSocketState State => _client?.State ?? WebSocketState.Closed; + public WebSocketCloseStatus? CloseStatus => _client?.CloseStatus; + public string? CloseStatusDescription => _client?.CloseStatusDescription; - public ShardConnection(JsonSerializerOptions jsonSerializerOptions, ILogger logger) + 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 { - _logger = logger.ForContext(); - _serializer = new(jsonSerializerOptions); + await _serializer.WritePacket(_client, packet); } - - public async Task Connect(string url, CancellationToken ct) + catch (Exception e) { - _client?.Dispose(); - _client = new ClientWebSocket(); - - await _client.ConnectAsync(GetConnectionUri(url), ct); + _logger.Error(e, "Error sending WebSocket message"); } + } - 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, "Error sending WebSocket message"); - } - } - - public async ValueTask DisposeAsync() - { - await CloseInner(WebSocketCloseStatus.NormalClosure, null); - _client?.Dispose(); - } - - 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, "Error reading from WebSocket"); - // force close so we can "reset" - await CloseInner(WebSocketCloseStatus.NormalClosure, null); - } - + 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, "Error reading from WebSocket"); + // force close so we can "reset" + await CloseInner(WebSocketCloseStatus.NormalClosure, null); } - private Uri GetConnectionUri(string baseUri) => new UriBuilder(baseUri) + return null; + } + + private Uri GetConnectionUri(string baseUri) => new UriBuilder(baseUri) { Query = "v=9&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) { - Query = "v=9&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, "Error closing WebSocket connection"); - } - } - - // This shouldn't need to be wrapped in a try/catch but doing it anyway :/ + // Close with timeout, mostly to work around https://github.com/dotnet/runtime/issues/51590 + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); try { - client.Dispose(); + await client.CloseAsync(closeStatus, description, cts.Token); } catch (Exception e) { - _logger.Error(e, "Error disposing WebSocket connection"); + _logger.Error(e, "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, "Error disposing WebSocket connection"); + } } } \ No newline at end of file diff --git a/Myriad/Gateway/ShardInfo.cs b/Myriad/Gateway/ShardInfo.cs index 8547f66d..ea1f7cb2 100644 --- a/Myriad/Gateway/ShardInfo.cs +++ b/Myriad/Gateway/ShardInfo.cs @@ -1,4 +1,3 @@ -namespace Myriad.Gateway -{ - public record ShardInfo(int ShardId, int NumShards); -} \ No newline at end of file +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 index 4d87bf0f..dc13a88a 100644 --- a/Myriad/Gateway/ShardPacketSerializer.cs +++ b/Myriad/Gateway/ShardPacketSerializer.cs @@ -1,70 +1,68 @@ -using System; using System.Buffers; -using System.IO; using System.Net.WebSockets; using System.Text.Json; -using System.Threading.Tasks; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public class ShardPacketSerializer { - public class ShardPacketSerializer + private const int BufferSize = 64 * 1024; + + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public ShardPacketSerializer(JsonSerializerOptions jsonSerializerOptions) { - private const int BufferSize = 64 * 1024; + _jsonSerializerOptions = jsonSerializerOptions; + } - private readonly JsonSerializerOptions _jsonSerializerOptions; + public async ValueTask<(WebSocketMessageType type, GatewayPacket? packet)> ReadPacket(ClientWebSocket socket) + { + using var buf = MemoryPool.Shared.Rent(BufferSize); - public ShardPacketSerializer(JsonSerializerOptions jsonSerializerOptions) + 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) { - _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); + res = await socket.ReceiveAsync(buf.Memory, default); 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); - } + return DeserializeObject(res, stream.GetBuffer().AsSpan(0, (int)stream.Length)); + } - private (WebSocketMessageType type, GatewayPacket packet) DeserializeObject(ValueWebSocketReceiveResult res, Span span) - { - var packet = JsonSerializer.Deserialize(span, _jsonSerializerOptions)!; - return (res.MessageType, packet); - } + 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 index f1762bf7..f4238365 100644 --- a/Myriad/Gateway/State/HeartbeatWorker.cs +++ b/Myriad/Gateway/State/HeartbeatWorker.cs @@ -1,63 +1,58 @@ -using System; -using System.Threading; -using System.Threading.Tasks; +namespace Myriad.Gateway.State; -namespace Myriad.Gateway.State +public class HeartbeatWorker: IAsyncDisposable { - public class HeartbeatWorker: IAsyncDisposable + private Task? _worker; + private CancellationTokenSource? _workerCts; + + public TimeSpan? CurrentHeartbeatInterval { get; private set; } + + public async ValueTask DisposeAsync() { - private Task? _worker; - private CancellationTokenSource? _workerCts; + await Stop(); + } - public TimeSpan? CurrentHeartbeatInterval { get; private set; } - - 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); - - public async ValueTask DisposeAsync() - { + 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 index f7843ec2..cf89534a 100644 --- a/Myriad/Gateway/State/ShardState.cs +++ b/Myriad/Gateway/State/ShardState.cs @@ -1,11 +1,10 @@ -namespace Myriad.Gateway.State +namespace Myriad.Gateway.State; + +public enum ShardState { - public enum ShardState - { - Disconnected, - Handshaking, - Identifying, - Connected, - Reconnecting - } + 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 index 04b77a40..cba968d6 100644 --- a/Myriad/Gateway/State/ShardStateManager.cs +++ b/Myriad/Gateway/State/ShardStateManager.cs @@ -1,246 +1,246 @@ -using System; using System.Net.WebSockets; using System.Text.Json; -using System.Threading.Tasks; using Myriad.Gateway.State; using Myriad.Types; using Serilog; -namespace Myriad.Gateway +namespace Myriad.Gateway; + +public class ShardStateManager { - 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) { - private readonly HeartbeatWorker _heartbeatWorker = new(); - private readonly ILogger _logger; + _info = info; + _jsonSerializerOptions = jsonSerializerOptions; + _logger = logger.ForContext(); + } - private readonly ShardInfo _info; - private readonly JsonSerializerOptions _jsonSerializerOptions; - private ShardState _state = ShardState.Disconnected; + public ShardState State { get; private set; } = ShardState.Disconnected; - private DateTimeOffset? _lastHeartbeatSent; - private TimeSpan? _latency; - private bool _hasReceivedHeartbeatAck; + public TimeSpan? Latency => _latency; + public User? User { get; private set; } + public ApplicationPartial? Application { get; private set; } - private string? _sessionId; - private int? _lastSeq; + 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 ShardState State => _state; - public TimeSpan? Latency => _latency; - public User? User { get; private set; } - public ApplicationPartial? Application { get; private set; } + public event Action OnHeartbeatReceived; - 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 Task HandleConnectionOpened() + { + State = ShardState.Handshaking; + return Task.CompletedTask; + } - public event Action OnHeartbeatReceived; + public async Task HandleConnectionClosed() + { + _latency = null; + await _heartbeatWorker.Stop(); + } - public ShardStateManager(ShardInfo info, JsonSerializerOptions jsonSerializerOptions, ILogger logger) + public async Task HandlePacketReceived(GatewayPacket packet) + { + switch (packet.Opcode) { - _info = info; - _jsonSerializerOptions = jsonSerializerOptions; - _logger = logger.ForContext(); - } + case GatewayOpcode.Hello: + var hello = DeserializePayload(packet); + await HandleHello(hello); + break; - public Task HandleConnectionOpened() - { - _state = ShardState.Handshaking; - return Task.CompletedTask; - } + case GatewayOpcode.Heartbeat: + await HandleHeartbeatRequest(); + break; - public async Task HandleConnectionClosed() - { - _latency = null; - await _heartbeatWorker.Stop(); - } + case GatewayOpcode.HeartbeatAck: + await HandleHeartbeatAck(); + break; - public async Task HandlePacketReceived(GatewayPacket packet) - { - switch (packet.Opcode) - { - case GatewayOpcode.Hello: - var hello = DeserializePayload(packet); - await HandleHello(hello); + case GatewayOpcode.Reconnect: + { + await HandleReconnect(); break; + } - case GatewayOpcode.Heartbeat: - await HandleHeartbeatRequest(); + case GatewayOpcode.InvalidSession: + { + var canResume = DeserializePayload(packet); + await HandleInvalidSession(canResume); break; + } - case GatewayOpcode.HeartbeatAck: - await HandleHeartbeatAck(); - break; + case GatewayOpcode.Dispatch: + _lastSeq = packet.Sequence; - case GatewayOpcode.Reconnect: - { - await HandleReconnect(); - break; - } + var evt = DeserializeEvent(packet.EventType!, (JsonElement)packet.Payload!); + if (evt != null) + { + if (evt is ReadyEvent ready) + await HandleReady(ready); - case GatewayOpcode.InvalidSession: - { - var canResume = DeserializePayload(packet); - await HandleInvalidSession(canResume); - break; - } + if (evt is ResumedEvent) + await HandleResumed(); - case GatewayOpcode.Dispatch: - _lastSeq = packet.Sequence; + await HandleEvent(evt); + } - var evt = DeserializeEvent(packet.EventType!, (JsonElement)packet.Payload!); - if (evt != null) - { - if (evt is ReadyEvent ready) - await HandleReady(ready); + break; + } + } - if (evt is ResumedEvent) - await HandleResumed(); + private async Task HandleHello(GatewayHello hello) + { + var interval = TimeSpan.FromMilliseconds(hello.HeartbeatInterval); - await HandleEvent(evt); - } - break; - } + _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; } - private async Task HandleHello(GatewayHello hello) - { - var interval = TimeSpan.FromMilliseconds(hello.HeartbeatInterval); + _logger.Information("Shard {ShardId}: Received Invalid Session (can resume? {CanResume})", + _info.ShardId, canResume); - _hasReceivedHeartbeatAck = true; - await _heartbeatWorker.Start(interval, HandleHeartbeatTimer); - await IdentifyOrResume(); + 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; } - private async Task IdentifyOrResume() + 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)) { - _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(); - } + _logger.Debug("Shard {ShardId}: Received unknown event type {EventType}", _info.ShardId, eventType); + return null; } - private Task HandleHeartbeatAck() + try { - _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; + _logger.Verbose("Shard {ShardId}: Deserializing {EventType} to {ClrType}", _info.ShardId, eventType, + clrType); + return JsonSerializer.Deserialize(payload.GetRawText(), clrType, _jsonSerializerOptions) + as IGatewayEvent; } - - private async Task HandleInvalidSession(bool canResume) + catch (JsonException e) { - 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; - } + _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 index 018313e4..89ad23c5 100644 --- a/Myriad/Myriad.csproj +++ b/Myriad/Myriad.csproj @@ -1,10 +1,11 @@ - net5.0 + net6.0 enable + enable - + $(NoWarn);8618 @@ -20,10 +21,10 @@ - - - - + + + + diff --git a/Myriad/Rest/BaseRestClient.cs b/Myriad/Rest/BaseRestClient.cs index 76a32693..f7ce68d5 100644 --- a/Myriad/Rest/BaseRestClient.cs +++ b/Myriad/Rest/BaseRestClient.cs @@ -1,13 +1,9 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; using System.Net; -using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Myriad.Rest.Exceptions; using Myriad.Rest.Ratelimit; @@ -19,305 +15,306 @@ using Polly; using Serilog; using Serilog.Context; -namespace Myriad.Rest +namespace Myriad.Rest; + +public class BaseRestClient: IAsyncDisposable { - public class BaseRestClient: IAsyncDisposable + private readonly string _baseUrl; + private readonly Version _httpVersion = new(2, 0); + 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) { - private readonly Version _httpVersion = new(2, 0); - private readonly JsonSerializerOptions _jsonSerializerOptions; - private readonly ILogger _logger; - private readonly Ratelimiter _ratelimiter; - private readonly AsyncPolicy _retryPolicy; - private readonly string _baseUrl; + _logger = logger.ForContext(); + _baseUrl = baseUrl; - public BaseRestClient(string userAgent, string token, ILogger logger, string baseUrl) - { - _logger = logger.ForContext(); - _baseUrl = baseUrl; + if (!token.StartsWith("Bot ")) + token = "Bot " + token; - if (!token.StartsWith("Bot ")) - token = "Bot " + token; + Client = new HttpClient(); + Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent); + Client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", token); - Client = new HttpClient(); - Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent); - Client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", token); + _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad(); - _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad(); + _ratelimiter = new Ratelimiter(logger); + var discordPolicy = new DiscordRateLimitPolicy(_ratelimiter); - _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)); - // 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(); - var waitPolicy = Policy - .Handle() - .WaitAndRetryAsync(3, - (_, e, _) => ((RatelimitBucketExhaustedException)e).RetryAfter, - (_, _, _, _) => Task.CompletedTask) - .AsAsyncPolicy(); + _retryPolicy = Policy.WrapAsync(timeoutPolicy, waitPolicy, discordPolicy); + } - _retryPolicy = Policy.WrapAsync(timeoutPolicy, waitPolicy, discordPolicy); - } + public HttpClient Client { get; } - public HttpClient Client { get; } - public EventHandler<(string, int, long)> OnResponseEvent; + public ValueTask DisposeAsync() + { + _ratelimiter.Dispose(); + Client.Dispose(); + return default; + } - 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.RequestVersionOrHigher; - - 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, 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) - _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"); - } + 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; - } - private string NormalizeRoutePath(string url) + 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(() => { - 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}"); + var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path); + SetRequestJsonBody(request, body); + return request; + }, ratelimitParams); + return await ReadResponse(response); + } - // catch-all for missed IDs - url = Regex.Replace(url, @"\d{17,19}", "{snowflake}"); - - return url; - } - - private string GetEndpointMetricsName(HttpRequestMessage req) + public async Task PostMultipart(string path, (string endpointName, ulong major) ratelimitParams, + object? payload, MultipartFile[]? files) + where T : class + { + using var response = await Send(() => { - var localPath = Regex.Replace(req.RequestUri!.LocalPath, @"/api/v\d+", ""); - var routePath = NormalizeRoutePath(localPath); - return $"{req.Method} {routePath}"; - } + var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path); + SetRequestFormDataBody(request, payload, files); + return request; + }, ratelimitParams); + return await ReadResponse(response); + } - private string CleanForLogging(Uri uri) + public async Task Patch(string path, (string endpointName, ulong major) ratelimitParams, object? body) + where T : class + { + using var response = await Send(() => { - var path = uri.ToString(); + var request = new HttpRequestMessage(HttpMethod.Patch, _baseUrl + path); + SetRequestJsonBody(request, body); + return request; + }, ratelimitParams); + return await ReadResponse(response); + } - // 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"); + 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); + } - // remove base URL - path = path.Substring(_baseUrl.Length); + public async Task Delete(string path, (string endpointName, ulong major) ratelimitParams) + { + using var _ = await Send(() => new HttpRequestMessage(HttpMethod.Delete, _baseUrl + path), ratelimitParams); + } - return path; + 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.RequestVersionOrHigher; + + 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, + 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) + _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 index 601a1309..7b1f509f 100644 --- a/Myriad/Rest/DiscordApiClient.cs +++ b/Myriad/Rest/DiscordApiClient.cs @@ -1,6 +1,4 @@ -using System; using System.Net; -using System.Threading.Tasks; using Myriad.Rest.Types; using Myriad.Rest.Types.Requests; @@ -8,143 +6,146 @@ using Myriad.Types; using Serilog; -namespace Myriad.Rest +namespace Myriad.Rest; + +public class DiscordApiClient { - 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/v9"; + private readonly BaseRestClient _client; + + public EventHandler<(string, int, long)> OnResponseEvent; + + public DiscordApiClient(string token, ILogger logger, string? baseUrl = null) { - public const string UserAgent = "DiscordBot (https://github.com/xSke/PluralKit/tree/main/Myriad/, v1)"; - private const string DefaultApiBaseUrl = "https://discord.com/api/v9"; - private readonly BaseRestClient _client; - - 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 EventHandler<(string, int, long)> OnResponseEvent; - - 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"); + _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 index 55d6640a..31e3d80d 100644 --- a/Myriad/Rest/DiscordApiError.cs +++ b/Myriad/Rest/DiscordApiError.cs @@ -1,9 +1,8 @@ using System.Text.Json; -namespace Myriad.Rest +namespace Myriad.Rest; + +public record DiscordApiError(string Message, int Code) { - public record DiscordApiError(string Message, int Code) - { - public JsonElement? Errors { get; init; } - } + 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 index 3a803081..69486cfa 100644 --- a/Myriad/Rest/Exceptions/DiscordRequestException.cs +++ b/Myriad/Rest/Exceptions/DiscordRequestException.cs @@ -1,77 +1,75 @@ -using System; using System.Net; -using System.Net.Http; -namespace Myriad.Rest.Exceptions +namespace Myriad.Rest.Exceptions; + +public class DiscordRequestException: Exception { - public class DiscordRequestException: Exception + public DiscordRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) { - 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(); + ResponseBody = responseBody; + Response = response; + ApiError = apiError; } - public class NotFoundException: DiscordRequestException - { - public NotFoundException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base( + 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 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 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 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 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 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) { } - } +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 index 0c613150..14125fc3 100644 --- a/Myriad/Rest/Exceptions/RatelimitException.cs +++ b/Myriad/Rest/Exceptions/RatelimitException.cs @@ -1,29 +1,26 @@ -using System; - using Myriad.Rest.Ratelimit; -namespace Myriad.Rest.Exceptions +namespace Myriad.Rest.Exceptions; + +public class RatelimitException: Exception { - 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") { - public RatelimitException(string? message) : base(message) { } + Bucket = bucket; + RetryAfter = retryAfter; } - 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 Bucket Bucket { get; } - public TimeSpan RetryAfter { get; } - } - - public class GloballyRatelimitedException: RatelimitException - { - public GloballyRatelimitedException() : base("Global rate limit hit") { } - } +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 index 1acb6d7e..6c19da41 100644 --- a/Myriad/Rest/Ratelimit/Bucket.cs +++ b/Myriad/Rest/Ratelimit/Bucket.cs @@ -1,173 +1,172 @@ -using System; -using System.Threading; - using Serilog; -namespace Myriad.Rest.Ratelimit +namespace Myriad.Rest.Ratelimit; + +public class Bucket { - 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) { - private static readonly TimeSpan Epsilon = TimeSpan.FromMilliseconds(10); - private static readonly TimeSpan FallbackDelay = TimeSpan.FromMilliseconds(200); + _logger = logger.ForContext(); - private static readonly TimeSpan StaleTimeout = TimeSpan.FromSeconds(5); + Key = key; + Major = major; - private readonly ILogger _logger; - private readonly SemaphoreSlim _semaphore = new(1, 1); + Limit = limit; + Remaining = limit; + _resetTimeValid = false; + } - private DateTimeOffset? _nextReset; - private bool _resetTimeValid; - private bool _hasReceivedHeaders; + public string Key { get; } + public ulong Major { get; } - public Bucket(ILogger logger, string key, ulong major, int limit) + 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 { - _logger = logger.ForContext(); + _semaphore.Wait(); - 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 + if (Remaining > 0) { - _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", + _logger.Verbose( + "{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit} left], allowing through", Key, Major, Remaining, Limit); - return false; - } - finally - { - _semaphore.Release(); + Remaining--; + + return true; } + + _logger.Debug("{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit}] left, denying", + Key, Major, Remaining, Limit); + return false; } - - public void HandleResponse(RatelimitHeaders headers) + finally { - 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; + _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 index 9a465c6d..a89d5f77 100644 --- a/Myriad/Rest/Ratelimit/BucketManager.cs +++ b/Myriad/Rest/Ratelimit/BucketManager.cs @@ -1,82 +1,79 @@ -using System; using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; using Serilog; -namespace Myriad.Rest.Ratelimit +namespace Myriad.Rest.Ratelimit; + +public class BucketManager: IDisposable { - 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) { - 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(); + _logger = logger.ForContext(); + _worker = PruneWorker(_workerCts.Token); + } - private readonly ConcurrentDictionary _endpointKeyMap = new(); - private readonly ConcurrentDictionary _knownKeyLimits = new(); + public void Dispose() + { + _workerCts.Dispose(); + _worker.Dispose(); + } - private readonly ILogger _logger; + public Bucket? GetBucket(string endpoint, ulong major) + { + if (!_endpointKeyMap.TryGetValue(endpoint, out var key)) + return null; - private readonly Task _worker; - private readonly CancellationTokenSource _workerCts = new(); + if (_buckets.TryGetValue((key, major), out var bucket)) + return bucket; - public BucketManager(ILogger logger) + 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) { - _logger = logger.ForContext(); - _worker = PruneWorker(_workerCts.Token); + await Task.Delay(PruneWorkerInterval, ct); + PruneStaleBuckets(DateTimeOffset.UtcNow); } + } - public void Dispose() + private void PruneStaleBuckets(DateTimeOffset now) + { + foreach (var (key, bucket) in _buckets) { - _workerCts.Dispose(); - _worker.Dispose(); - } + if (now - bucket.LastUsed <= StaleBucketTimeout) + continue; - 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 _); - } + _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 index 9faaa0ad..82c4163c 100644 --- a/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs +++ b/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs @@ -1,46 +1,40 @@ -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - using Polly; -namespace Myriad.Rest.Ratelimit +namespace Myriad.Rest.Ratelimit; + +public class DiscordRateLimitPolicy: AsyncPolicy { - 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) { - public const string EndpointContextKey = "Endpoint"; - public const string MajorContextKey = "Major"; + _ratelimiter = ratelimiter; + } - private readonly 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"); - public DiscordRateLimitPolicy(Ratelimiter ratelimiter, PolicyBuilder? policyBuilder = null) - : base(policyBuilder) - { - _ratelimiter = ratelimiter; - } + if (!context.TryGetValue(MajorContextKey, out var majorObj) || !(majorObj is ulong major)) + throw new ArgumentException("Must provide major in Polly context"); - 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"); + // Check rate limit, throw if we're not allowed... + _ratelimiter.AllowRequestOrThrow(endpoint, major, DateTimeOffset.Now); - if (!context.TryGetValue(MajorContextKey, out var majorObj) || !(majorObj is ulong major)) - throw new ArgumentException("Must provide major in Polly context"); + // We're OK, push it through + var response = await action(context, ct).ConfigureAwait(continueOnCapturedContext); - // Check rate limit, throw if we're not allowed... - _ratelimiter.AllowRequestOrThrow(endpoint, major, DateTimeOffset.Now); + // Update rate limit state with headers + var headers = RatelimitHeaders.Parse(response); + _ratelimiter.HandleResponse(headers, endpoint, major); - // 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; - } + return response; } } \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/RatelimitHeaders.cs b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs index 70e453bb..423b3beb 100644 --- a/Myriad/Rest/Ratelimit/RatelimitHeaders.cs +++ b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs @@ -1,85 +1,79 @@ -using System; using System.Globalization; -using System.Linq; -using System.Net.Http; -namespace Myriad.Rest.Ratelimit +namespace Myriad.Rest.Ratelimit; + +public record RatelimitHeaders { - 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) { - 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 RatelimitHeaders() { } - - public static RatelimitHeaders Parse(HttpResponseMessage response) + var headers = new RatelimitHeaders { - var headers = new RatelimitHeaders - { - ServerDate = response.Headers.Date, - Limit = TryGetInt(response, LimitHeader), - Remaining = TryGetInt(response, RemainingHeader), - Bucket = TryGetHeader(response, BucketHeader) - }; + 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 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 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; + var global = TryGetHeader(response, GlobalHeader); + if (global != null && bool.TryParse(global, out var globalBool)) + headers.Global = globalBool; - return headers; - } + return headers; + } - private static string? TryGetHeader(HttpResponseMessage response, string headerName) - { - if (!response.Headers.TryGetValues(headerName, out var values)) - return null; + private static string? TryGetHeader(HttpResponseMessage response, string headerName) + { + if (!response.Headers.TryGetValues(headerName, out var values)) + return null; - return values.FirstOrDefault(); - } + return values.FirstOrDefault(); + } - private static int? TryGetInt(HttpResponseMessage response, string headerName) - { - var valueString = TryGetHeader(response, headerName); + 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; + if (!int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + return null; - return value; - } + return value; + } - private static double? TryGetDouble(HttpResponseMessage response, string headerName) - { - var valueString = TryGetHeader(response, headerName); + 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; + if (!double.TryParse(valueString, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + return null; - return value; - } + return value; } } \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/Ratelimiter.cs b/Myriad/Rest/Ratelimit/Ratelimiter.cs index 52613ba3..b06e5bcf 100644 --- a/Myriad/Rest/Ratelimit/Ratelimiter.cs +++ b/Myriad/Rest/Ratelimit/Ratelimiter.cs @@ -1,86 +1,83 @@ -using System; - using Myriad.Rest.Exceptions; using Serilog; -namespace Myriad.Rest.Ratelimit +namespace Myriad.Rest.Ratelimit; + +public class Ratelimiter: IDisposable { - public class Ratelimiter: IDisposable + private readonly BucketManager _buckets; + private readonly ILogger _logger; + + private DateTimeOffset? _globalRateLimitExpiry; + + public Ratelimiter(ILogger logger) { - private readonly BucketManager _buckets; - private readonly ILogger _logger; + _logger = logger.ForContext(); + _buckets = new BucketManager(logger); + } - private DateTimeOffset? _globalRateLimitExpiry; + public void Dispose() + { + _buckets.Dispose(); + } - public Ratelimiter(ILogger logger) + public void AllowRequestOrThrow(string endpoint, ulong major, DateTimeOffset now) + { + if (IsGloballyRateLimited(now)) { - _logger = logger.ForContext(); - _buckets = new BucketManager(logger); + _logger.Warning("Globally rate limited until {GlobalRateLimitExpiry}, cancelling request", + _globalRateLimitExpiry); + throw new GloballyRatelimitedException(); } - public void Dispose() + var bucket = _buckets.GetBucket(endpoint, major); + if (bucket == null) { - _buckets.Dispose(); + // No rate limit for this endpoint (yet), allow through + _logger.Debug("No rate limit data for endpoint {Endpoint}, allowing through", endpoint); + return; } - public void AllowRequestOrThrow(string endpoint, ulong major, DateTimeOffset now) + 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) { - if (IsGloballyRateLimited(now)) - { - _logger.Warning("Globally rate limited until {GlobalRateLimitExpiry}, cancelling request", - _globalRateLimitExpiry); - throw new GloballyRatelimitedException(); - } + _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); - 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); + bucket?.HandleResponse(headers); } - - 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; } + + 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 index cd26e7cf..410de717 100644 --- a/Myriad/Rest/Types/AllowedMentions.cs +++ b/Myriad/Rest/Types/AllowedMentions.cs @@ -2,21 +2,20 @@ using System.Text.Json.Serialization; using Myriad.Serialization; -namespace Myriad.Rest.Types -{ - public record AllowedMentions - { - [JsonConverter(typeof(JsonSnakeCaseStringEnumConverter))] - public enum ParseType - { - Roles, - Users, - Everyone - } +namespace Myriad.Rest.Types; - public ParseType[]? Parse { get; set; } - public ulong[]? Users { get; set; } - public ulong[]? Roles { get; set; } - public bool RepliedUser { get; set; } +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 index 1f1d5bfd..b20c6cb4 100644 --- a/Myriad/Rest/Types/MultipartFile.cs +++ b/Myriad/Rest/Types/MultipartFile.cs @@ -1,6 +1,3 @@ -using System.IO; +namespace Myriad.Rest.Types; -namespace Myriad.Rest.Types -{ - public record MultipartFile(string Filename, Stream Data, string? Description); -} \ No newline at end of file +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 index 81fb1133..3be47d0c 100644 --- a/Myriad/Rest/Types/Requests/CommandRequest.cs +++ b/Myriad/Rest/Types/Requests/CommandRequest.cs @@ -1,13 +1,10 @@ -using System.Collections.Generic; - using Myriad.Types; -namespace Myriad.Rest.Types +namespace Myriad.Rest.Types; + +public record ApplicationCommandRequest { - public record ApplicationCommandRequest - { - public string Name { get; init; } - public string Description { get; init; } - public List? Options { get; init; } - } + 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 index 2d17bc29..a402d6a0 100644 --- a/Myriad/Rest/Types/Requests/CreateDmRequest.cs +++ b/Myriad/Rest/Types/Requests/CreateDmRequest.cs @@ -1,4 +1,3 @@ -namespace Myriad.Rest.Types.Requests -{ - public record CreateDmRequest(ulong RecipientId); -} \ No newline at end of file +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 index 5cbbf714..1721447e 100644 --- a/Myriad/Rest/Types/Requests/CreateWebhookRequest.cs +++ b/Myriad/Rest/Types/Requests/CreateWebhookRequest.cs @@ -1,4 +1,3 @@ -namespace Myriad.Rest.Types.Requests -{ - public record CreateWebhookRequest(string Name); -} \ No newline at end of file +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 index c8db9e29..6980b696 100644 --- a/Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs +++ b/Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs @@ -1,14 +1,13 @@ using Myriad.Types; -namespace Myriad.Rest.Types.Requests +namespace Myriad.Rest.Types.Requests; + +public record ExecuteWebhookRequest { - public record ExecuteWebhookRequest - { - public string? Content { get; init; } - public string? Username { get; init; } - public string? AvatarUrl { get; init; } - public Embed[] Embeds { get; init; } - public Message.Attachment[] Attachments { get; set; } - public AllowedMentions? AllowedMentions { get; init; } - } + public string? Content { get; init; } + public string? Username { get; init; } + public string? AvatarUrl { get; init; } + public Embed[] Embeds { 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 index 93f88bb6..fb2b7cb5 100644 --- a/Myriad/Rest/Types/Requests/MessageEditRequest.cs +++ b/Myriad/Rest/Types/Requests/MessageEditRequest.cs @@ -3,23 +3,22 @@ using System.Text.Json.Serialization; using Myriad.Types; using Myriad.Utils; -namespace Myriad.Rest.Types.Requests +namespace Myriad.Rest.Types.Requests; + +public record MessageEditRequest { - public record MessageEditRequest - { - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional Content { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Content { get; init; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional Embed { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Embed { get; init; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional Flags { 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 AllowedMentions { get; init; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional Components { 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 index 0ea8ddc4..4128defc 100644 --- a/Myriad/Rest/Types/Requests/MessageRequest.cs +++ b/Myriad/Rest/Types/Requests/MessageRequest.cs @@ -1,14 +1,13 @@ using Myriad.Types; -namespace Myriad.Rest.Types.Requests +namespace Myriad.Rest.Types.Requests; + +public record MessageRequest { - 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? Embed { get; set; } - public MessageComponent[]? Components { get; set; } - } + public string? Content { get; set; } + public object? Nonce { get; set; } + public bool Tts { get; set; } + public AllowedMentions? AllowedMentions { get; set; } + public Embed? Embed { get; set; } + public MessageComponent[]? Components { get; set; } } \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs b/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs index a843c755..2f9b4c56 100644 --- a/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs +++ b/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs @@ -1,7 +1,6 @@ -namespace Myriad.Rest.Types +namespace Myriad.Rest.Types; + +public record ModifyGuildMemberRequest { - public record ModifyGuildMemberRequest - { - public string? Nick { get; init; } - } + 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 index 7d73e669..f9dc68c2 100644 --- a/Myriad/Rest/Types/Requests/WebhookMessageEditRequest.cs +++ b/Myriad/Rest/Types/Requests/WebhookMessageEditRequest.cs @@ -2,14 +2,13 @@ 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; } +namespace Myriad.Rest.Types.Requests; - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional AllowedMentions { get; init; } - } +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 index 5d3aac26..b1ec7945 100644 --- a/Myriad/Serialization/JsonSerializerOptionsExtensions.cs +++ b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs @@ -1,21 +1,20 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Myriad.Serialization +namespace Myriad.Serialization; + +public static class JsonSerializerOptionsExtensions { - public static class JsonSerializerOptionsExtensions + public static JsonSerializerOptions ConfigureForMyriad(this JsonSerializerOptions opts) { - public static JsonSerializerOptions ConfigureForMyriad(this JsonSerializerOptions opts) - { - opts.PropertyNamingPolicy = new JsonSnakeCaseNamingPolicy(); - opts.NumberHandling = JsonNumberHandling.AllowReadingFromString; - opts.IncludeFields = true; + 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()); + opts.Converters.Add(new PermissionSetJsonConverter()); + opts.Converters.Add(new ShardInfoJsonConverter()); + opts.Converters.Add(new OptionalConverterFactory()); - return opts; - } + return opts; } } \ No newline at end of file diff --git a/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs b/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs index f07e5c33..8fc596f4 100644 --- a/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs +++ b/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs @@ -1,88 +1,86 @@ -using System; using System.Text; using System.Text.Json; -namespace Myriad.Serialization +namespace Myriad.Serialization; + +// From https://github.com/J0rgeSerran0/JsonNamingPolicy/blob/master/JsonSnakeCaseNamingPolicy.cs, no NuGet :/ +public class JsonSnakeCaseNamingPolicy: JsonNamingPolicy { - // 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) { - private readonly string _separator = "_"; + if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(name)) return string.Empty; - public override string ConvertName(string name) + 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 (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) { - if (position != 0) + isCurrentSpace = spanName[position] == 32; + isPreviousSpace = spanName[position - 1] == 32; + isPreviousSeparator = spanName[position - 1] == 95; + + if (position + 1 != spanName.Length) { - isCurrentSpace = spanName[position] == 32; - isPreviousSpace = spanName[position - 1] == 32; - isPreviousSeparator = spanName[position - 1] == 95; + isNextLower = spanName[position + 1] > 96 && spanName[position + 1] < 123; + isNextUpper = spanName[position + 1] > 64 && spanName[position + 1] < 91; + isNextSpace = spanName[position + 1] == 32; + } - 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 (isCurrentSpace && - (isPreviousSpace || - isPreviousSeparator || - isNextUpper || - isNextSpace)) + if (isCurrentUpper && + (isPreviousLower || + isPreviousNumber || + isNextLower || + isNextSpace || + isNextLower && !isPreviousSpace)) { - addCharacter = false; + stringBuilder.Append(_separator); } 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)) + if (isCurrentSpace && + !isPreviousSpace && + !isNextSpace) { stringBuilder.Append(_separator); - } - else - { - if (isCurrentSpace && - !isPreviousSpace && - !isNextSpace) - { - stringBuilder.Append(_separator); - addCharacter = false; - } + addCharacter = false; } } } - - if (addCharacter) - stringBuilder.Append(spanName[position]); - else - addCharacter = true; } - return stringBuilder.ToString().ToLower(); + 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 index 840fc03e..087ad2b9 100644 --- a/Myriad/Serialization/JsonSnakeCaseStringEnumConverter.cs +++ b/Myriad/Serialization/JsonSnakeCaseStringEnumConverter.cs @@ -1,17 +1,15 @@ -using System; using System.Text.Json; using System.Text.Json.Serialization; -namespace Myriad.Serialization +namespace Myriad.Serialization; + +public class JsonSnakeCaseStringEnumConverter: JsonConverterFactory { - public class JsonSnakeCaseStringEnumConverter: JsonConverterFactory - { - private readonly JsonStringEnumConverter _inner = new(new JsonSnakeCaseNamingPolicy()); + private readonly JsonStringEnumConverter _inner = new(new JsonSnakeCaseNamingPolicy()); - public override bool CanConvert(Type typeToConvert) => - _inner.CanConvert(typeToConvert); + public override bool CanConvert(Type typeToConvert) => + _inner.CanConvert(typeToConvert); - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => - _inner.CreateConverter(typeToConvert, options); - } + 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 index 23b8bd6d..d0af8f0f 100644 --- a/Myriad/Serialization/JsonStringConverter.cs +++ b/Myriad/Serialization/JsonStringConverter.cs @@ -1,22 +1,20 @@ -using System; 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; - } +namespace Myriad.Serialization; - public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) - { - var inner = JsonSerializer.Serialize(value, options); - writer.WriteStringValue(inner); - } +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 index aad482fa..04c6c4ba 100644 --- a/Myriad/Serialization/OptionalConverter.cs +++ b/Myriad/Serialization/OptionalConverter.cs @@ -1,48 +1,47 @@ -using System; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using Myriad.Utils; -namespace Myriad.Serialization +namespace Myriad.Serialization; + +public class OptionalConverterFactory: JsonConverterFactory { - public class OptionalConverterFactory: JsonConverterFactory + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - public class Inner: JsonConverter> - { - public override Optional Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var inner = JsonSerializer.Deserialize(ref reader, options); - return new(inner!); - } + var innerType = typeToConvert.GetGenericArguments()[0]; + return (JsonConverter?)Activator.CreateInstance( + typeof(Inner<>).MakeGenericType(innerType), + BindingFlags.Instance | BindingFlags.Public, + null, + null, + null); + } - public override void Write(Utf8JsonWriter writer, Optional value, JsonSerializerOptions options) - { - JsonSerializer.Serialize(writer, value.HasValue ? value.GetValue() : default, typeof(T), options); - } + 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 JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, Optional value, 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; + 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 index 91dc9bed..edb5f4d8 100644 --- a/Myriad/Serialization/PermissionSetJsonConverter.cs +++ b/Myriad/Serialization/PermissionSetJsonConverter.cs @@ -1,24 +1,22 @@ -using System; using System.Text.Json; using System.Text.Json.Serialization; using Myriad.Types; -namespace Myriad.Serialization +namespace Myriad.Serialization; + +public class PermissionSetJsonConverter: JsonConverter { - public class PermissionSetJsonConverter: JsonConverter + public override PermissionSet Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - public override PermissionSet Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var str = reader.GetString(); - if (str == null) return default; + var str = reader.GetString(); + if (str == null) return default; - return (PermissionSet)ulong.Parse(str); - } + return (PermissionSet)ulong.Parse(str); + } - public override void Write(Utf8JsonWriter writer, PermissionSet value, JsonSerializerOptions options) - { - writer.WriteStringValue(((ulong)value).ToString()); - } + 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 index c2d58281..db67052a 100644 --- a/Myriad/Serialization/ShardInfoJsonConverter.cs +++ b/Myriad/Serialization/ShardInfoJsonConverter.cs @@ -1,28 +1,26 @@ -using System; using System.Text.Json; using System.Text.Json.Serialization; using Myriad.Gateway; -namespace Myriad.Serialization +namespace Myriad.Serialization; + +public class ShardInfoJsonConverter: JsonConverter { - public class ShardInfoJsonConverter: JsonConverter + public override ShardInfo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - 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"); + 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]); - } + 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(); - } + 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/Application/Application.cs b/Myriad/Types/Application/Application.cs index 8e4204f6..47ca4cff 100644 --- a/Myriad/Types/Application/Application.cs +++ b/Myriad/Types/Application/Application.cs @@ -1,25 +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; } - } +namespace Myriad.Types; - public record ApplicationPartial - { - public ulong Id { get; init; } - public int Flags { get; init; } - } +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 index af12b02f..097b222f 100644 --- a/Myriad/Types/Application/ApplicationCommand.cs +++ b/Myriad/Types/Application/ApplicationCommand.cs @@ -1,11 +1,10 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record ApplicationCommand { - 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; } - } + 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 index 7936f94a..48c75906 100644 --- a/Myriad/Types/Application/ApplicationCommandInteractionData.cs +++ b/Myriad/Types/Application/ApplicationCommandInteractionData.cs @@ -1,11 +1,10 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record ApplicationCommandInteractionData { - 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; } - } + 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 index 1ca3c6c8..6787370c 100644 --- a/Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs +++ b/Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs @@ -1,9 +1,8 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record ApplicationCommandInteractionDataOption { - public record ApplicationCommandInteractionDataOption - { - public string Name { get; init; } - public object? Value { get; init; } - public ApplicationCommandInteractionDataOption[]? Options { get; init; } - } + 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 index d99649e3..85533c7a 100644 --- a/Myriad/Types/Application/ApplicationCommandOption.cs +++ b/Myriad/Types/Application/ApplicationCommandOption.cs @@ -1,24 +1,23 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record ApplicationCommandOption(ApplicationCommandOption.OptionType Type, string Name, string Description) { - public record ApplicationCommandOption(ApplicationCommandOption.OptionType Type, string Name, string Description) + public enum OptionType { - 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); + 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 index 06c5614e..5969cfc4 100644 --- a/Myriad/Types/Application/Interaction.cs +++ b/Myriad/Types/Application/Interaction.cs @@ -1,22 +1,21 @@ -namespace Myriad.Types -{ - public record Interaction - { - public enum InteractionType - { - Ping = 1, - ApplicationCommand = 2, - MessageComponent = 3 - } +namespace Myriad.Types; - 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; } +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 index 894559f0..b9da3da3 100644 --- a/Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs +++ b/Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs @@ -3,26 +3,25 @@ using System.Text.Json.Serialization; using Myriad.Rest.Types; using Myriad.Utils; -namespace Myriad.Types +namespace Myriad.Types; + +public record InteractionApplicationCommandCallbackData { - public record InteractionApplicationCommandCallbackData - { - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional Tts { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Tts { get; init; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional Content { 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 Embeds { get; init; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional AllowedMentions { 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 Flags { get; init; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public Optional Components { 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 index 1b54e3f0..1b6d929d 100644 --- a/Myriad/Types/Application/InteractionResponse.cs +++ b/Myriad/Types/Application/InteractionResponse.cs @@ -1,17 +1,16 @@ -namespace Myriad.Types -{ - public record InteractionResponse - { - public enum ResponseType - { - Pong = 1, - ChannelMessageWithSource = 4, - DeferredChannelMessageWithSource = 5, - DeferredUpdateMessage = 6, - UpdateMessage = 7 - } +namespace Myriad.Types; - public ResponseType Type { get; init; } - public InteractionApplicationCommandCallbackData? Data { get; init; } +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 index 0c9758db..bca93019 100644 --- a/Myriad/Types/Channel.cs +++ b/Myriad/Types/Channel.cs @@ -1,45 +1,45 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record Channel { - public record Channel + public enum ChannelType { - 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 - } + 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 index de88ee7e..06b05523 100644 --- a/Myriad/Types/Component/ButtonStyle.cs +++ b/Myriad/Types/Component/ButtonStyle.cs @@ -1,11 +1,10 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public enum ButtonStyle { - public enum ButtonStyle - { - Primary = 1, - Secondary = 2, - Success = 3, - Danger = 4, - Link = 5 - } + 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 index 5641c08a..0b10a756 100644 --- a/Myriad/Types/Component/ComponentType.cs +++ b/Myriad/Types/Component/ComponentType.cs @@ -1,8 +1,7 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public enum ComponentType { - public enum ComponentType - { - ActionRow = 1, - Button = 2 - } + ActionRow = 1, + Button = 2 } \ No newline at end of file diff --git a/Myriad/Types/Component/MessageComponent.cs b/Myriad/Types/Component/MessageComponent.cs index 4beee8de..2240a2fe 100644 --- a/Myriad/Types/Component/MessageComponent.cs +++ b/Myriad/Types/Component/MessageComponent.cs @@ -1,14 +1,13 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record MessageComponent { - 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; } - } + 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 index 230d55d0..b71b633a 100644 --- a/Myriad/Types/Embed.cs +++ b/Myriad/Types/Embed.cs @@ -1,62 +1,61 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record Embed { - 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 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 EmbedFooter( + string Text, + string? IconUrl = null, + string? ProxyIconUrl = null + ); - public record EmbedImage( - string? Url, - uint? Width = null, - uint? Height = 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 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 EmbedVideo( + string? Url, + uint? Width = null, + uint? Height = null + ); - public record EmbedProvider( - string? Name, - string? Url - ); + public record EmbedProvider( + string? Name, + string? Url + ); - public record EmbedAuthor( - string? Name = null, - string? Url = null, - string? IconUrl = null, - string? ProxyIconUrl = null - ); + 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 - ); - } + 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 index 9ce76381..2e41b90a 100644 --- a/Myriad/Types/Emoji.cs +++ b/Myriad/Types/Emoji.cs @@ -1,9 +1,8 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record Emoji { - public record Emoji - { - public ulong? Id { get; init; } - public string? Name { get; init; } - public bool? Animated { get; init; } - } + 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 index 2f3ff2c1..ad554a47 100644 --- a/Myriad/Types/Gateway/GatewayInfo.cs +++ b/Myriad/Types/Gateway/GatewayInfo.cs @@ -1,13 +1,12 @@ -namespace Myriad.Types -{ - public record GatewayInfo - { - public string Url { get; init; } +namespace Myriad.Types; - public record Bot: GatewayInfo - { - public int Shards { get; init; } - public SessionStartLimit SessionStartLimit { get; init; } - } +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 index 954d695e..6a1ad4cc 100644 --- a/Myriad/Types/Gateway/SessionStartLimit.cs +++ b/Myriad/Types/Gateway/SessionStartLimit.cs @@ -1,10 +1,9 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record SessionStartLimit { - public record SessionStartLimit - { - public int Total { get; init; } - public int Remaining { get; init; } - public int ResetAfter { get; init; } - public int MaxConcurrency { get; init; } - } + 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 index 75764b8e..2e44237b 100644 --- a/Myriad/Types/Guild.cs +++ b/Myriad/Types/Guild.cs @@ -1,31 +1,30 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public enum PremiumTier { - public enum PremiumTier - { - NONE, - TIER_1, - TIER_2, - TIER_3, - } + 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 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; } - } + 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 index 0e610f24..097c8938 100644 --- a/Myriad/Types/GuildMember.cs +++ b/Myriad/Types/GuildMember.cs @@ -1,15 +1,14 @@ -namespace Myriad.Types -{ - public record GuildMember: GuildMemberPartial - { - public User User { get; init; } - } +namespace Myriad.Types; - public record GuildMemberPartial - { - public string? Avatar { get; init; } - public string? Nick { get; init; } - public ulong[] Roles { get; init; } - public string JoinedAt { get; init; } - } +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 index f60df07b..b2d2fff5 100644 --- a/Myriad/Types/Message.cs +++ b/Myriad/Types/Message.cs @@ -1,90 +1,89 @@ -using System; using System.Text.Json.Serialization; using Myriad.Utils; -namespace Myriad.Types +namespace Myriad.Types; + +public record Message { - public record Message + [Flags] + public enum MessageFlags { - [Flags] - public enum MessageFlags - { - Crossposted = 1 << 0, - IsCrosspost = 1 << 1, - SuppressEmbeds = 1 << 2, - SourceMessageDeleted = 1 << 3, - Urgent = 1 << 4, - Ephemeral = 1 << 6 - } + 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 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 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 Reaction[] Reactions { get; init; } + public bool Pinned { get; init; } + public ulong? WebhookId { 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 Attachment + { public ulong Id { get; init; } - public ulong ChannelId { get; init; } - public ulong? GuildId { 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 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 Attachment[] Attachments { get; init; } - public Embed[]? Embeds { get; init; } - public Reaction[] Reactions { get; init; } - public bool Pinned { get; init; } - public ulong? WebhookId { 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 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; } - } + 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 index 036aeb2e..2873a3ff 100644 --- a/Myriad/Types/PermissionSet.cs +++ b/Myriad/Types/PermissionSet.cs @@ -1,47 +1,44 @@ -using System; +namespace Myriad.Types; -namespace Myriad.Types +[Flags] +public enum PermissionSet: ulong { - [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, + 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, + // Special: + None = 0, + All = 0x7FFFFFFF, - Dm = ViewChannel | SendMessages | ReadMessageHistory | AddReactions | AttachFiles | EmbedLinks | - UseExternalEmojis | Connect | Speak | UseVad - } + 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 index 215dc320..4347b117 100644 --- a/Myriad/Types/Role.cs +++ b/Myriad/Types/Role.cs @@ -1,14 +1,13 @@ -namespace Myriad.Types +namespace Myriad.Types; + +public record Role { - 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; } - } + 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/User.cs b/Myriad/Types/User.cs index 81426213..359ab1fb 100644 --- a/Myriad/Types/User.cs +++ b/Myriad/Types/User.cs @@ -1,38 +1,35 @@ -using System; +namespace Myriad.Types; -namespace Myriad.Types +public record User { - public record User + [Flags] + public enum Flags { - [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 - } + 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 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; } - } + 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 index 4b16906f..a3688860 100644 --- a/Myriad/Types/Webhook.cs +++ b/Myriad/Types/Webhook.cs @@ -1,21 +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; } - } +namespace Myriad.Types; - public enum WebhookType - { - Incoming = 1, - ChannelFollower = 2 - } +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 index 7742e4f9..182b015f 100644 --- a/Myriad/Utils/Optional.cs +++ b/Myriad/Utils/Optional.cs @@ -1,26 +1,25 @@ -namespace Myriad.Utils +namespace Myriad.Utils; + +public interface IOptional { - public interface IOptional + object? GetValue(); +} + +public readonly struct Optional: IOptional +{ + public Optional(T value) { - object? GetValue(); + HasValue = true; + Value = value; } - public readonly struct Optional: IOptional - { - public Optional(T value) - { - HasValue = true; - Value = value; - } + public bool HasValue { get; } + public object? GetValue() => Value; - public bool HasValue { get; } - public object? GetValue() => Value; + public T Value { get; } - public T Value { get; } + public static implicit operator Optional(T value) => new(value); - public static implicit operator Optional(T value) => new(value); - - public static Optional Some(T value) => new(value); - public static Optional None() => default; - } + 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 index 51a513e5..37b0efbe 100644 --- a/Myriad/packages.lock.json +++ b/Myriad/packages.lock.json @@ -1,31 +1,31 @@ { - "version": 1, - "dependencies": { - ".NETCoreApp,Version=v5.0": { - "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==" - }, - "System.Linq.Async": { - "type": "Direct", - "requested": "[5.0.0, )", - "resolved": "5.0.0", - "contentHash": "cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==" - } + "version": 1, + "dependencies": { + "net6.0": { + "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==" + }, + "System.Linq.Async": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==" + } + } } - } } \ No newline at end of file diff --git a/PluralKit.API/APIJsonExt.cs b/PluralKit.API/APIJsonExt.cs index ee57a500..953e6cfe 100644 --- a/PluralKit.API/APIJsonExt.cs +++ b/PluralKit.API/APIJsonExt.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -8,60 +5,58 @@ using NodaTime; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public static class APIJsonExt { - public static class APIJsonExt + public static JArray ToJSON(this IEnumerable shards) { - public static JArray ToJSON(this IEnumerable shards) + var o = new JArray(); + + foreach (var shard in shards) { - var o = new JArray(); + var s = new JObject(); + s.Add("id", shard.Id); - foreach (var shard in shards) - { - var s = new JObject(); - s.Add("id", shard.Id); + if (shard.Status == PKShardInfo.ShardStatus.Down) + s.Add("status", "down"); + else + s.Add("status", "up"); - if (shard.Status == PKShardInfo.ShardStatus.Down) - s.Add("status", "down"); - else - s.Add("status", "up"); + s.Add("ping", shard.Ping); + s.Add("last_heartbeat", shard.LastHeartbeat.ToString()); + s.Add("last_connection", shard.LastConnection.ToString()); - s.Add("ping", shard.Ping); - s.Add("last_heartbeat", shard.LastHeartbeat.ToString()); - s.Add("last_connection", shard.LastConnection.ToString()); - - o.Add(s); - } - - return o; + o.Add(s); } - public static JObject ToJson(this ModelRepository.Counts counts) - { - 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); - - return o; - } + return o; } - public struct FrontersReturnNew + public static JObject ToJson(this ModelRepository.Counts counts) { - [JsonProperty("id")] public Guid Uuid { get; set; } - [JsonProperty("timestamp")] public Instant Timestamp { get; set; } - [JsonProperty("members")] public IEnumerable Members { get; set; } - } + var o = new JObject(); - public struct SwitchesReturnNew - { - [JsonProperty("id")] public Guid Uuid { get; set; } - [JsonProperty("timestamp")] public Instant Timestamp { get; set; } - [JsonProperty("members")] public IEnumerable Members { get; set; } - } + 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); + 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 6a7825e4..bf7d0ff6 100644 --- a/PluralKit.API/ApiConfig.cs +++ b/PluralKit.API/ApiConfig.cs @@ -1,7 +1,6 @@ -namespace PluralKit.API +namespace PluralKit.API; + +public class ApiConfig { - public class ApiConfig - { - public int Port { get; set; } = 5000; - } + public int Port { get; set; } = 5000; } \ No newline at end of file diff --git a/PluralKit.API/Authentication/AuthExt.cs b/PluralKit.API/Authentication/AuthExt.cs index 88389524..08c8c0b9 100644 --- a/PluralKit.API/Authentication/AuthExt.cs +++ b/PluralKit.API/Authentication/AuthExt.cs @@ -1,32 +1,30 @@ -using System; using System.Security.Claims; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public static class AuthExt { - public static class AuthExt + public static SystemId CurrentSystem(this ClaimsPrincipal user) { - public static SystemId CurrentSystem(this ClaimsPrincipal user) - { - var claim = user.FindFirst(PKClaims.SystemId); - if (claim == null) throw new ArgumentException("User is unauthorized"); + 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"); - } + 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, 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; - } + 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 index 1991e3ba..bcb98c3d 100644 --- a/PluralKit.API/Authentication/PKClaims.cs +++ b/PluralKit.API/Authentication/PKClaims.cs @@ -1,7 +1,6 @@ -namespace PluralKit.API +namespace PluralKit.API; + +public class PKClaims { - public class PKClaims - { - public const string SystemId = "PluralKit:SystemId"; - } + 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 index 759d4b87..7b5b1b6c 100644 --- a/PluralKit.API/Authentication/SystemTokenAuthenticationHandler.cs +++ b/PluralKit.API/Authentication/SystemTokenAuthenticationHandler.cs @@ -1,50 +1,46 @@ -using System; -using System.Linq; using System.Security.Claims; using System.Text.Encodings.Web; -using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; using Dapper; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public class SystemTokenAuthenticationHandler: AuthenticationHandler { - 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) { - 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(); - // todo: move this to ModelRepository - 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 - { - - } + _db = db; } + + protected override async Task HandleAuthenticateAsync() + { + if (!Request.Headers.ContainsKey("Authorization")) + return AuthenticateResult.NoResult(); + + var token = Request.Headers["Authorization"].FirstOrDefault(); + // todo: move this to ModelRepository + 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 index 8a524352..7bae4216 100644 --- a/PluralKit.API/Authorization/MemberOwnerHandler.cs +++ b/PluralKit.API/Authorization/MemberOwnerHandler.cs @@ -1,20 +1,17 @@ -using System.Threading.Tasks; - using Microsoft.AspNetCore.Authorization; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public class MemberOwnerHandler: AuthorizationHandler { - public class MemberOwnerHandler: AuthorizationHandler + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + OwnSystemRequirement requirement, PKMember resource) { - 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; - } + 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 index 32872d3d..4b8b0190 100644 --- a/PluralKit.API/Authorization/MemberPrivacyHandler.cs +++ b/PluralKit.API/Authorization/MemberPrivacyHandler.cs @@ -1,21 +1,18 @@ -using System.Threading.Tasks; - using Microsoft.AspNetCore.Authorization; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public class MemberPrivacyHandler: AuthorizationHandler, PKMember> { - public class MemberPrivacyHandler: AuthorizationHandler, PKMember> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + PrivacyRequirement requirement, PKMember resource) { - 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; - } + 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 index 036d7d83..0fed617a 100644 --- a/PluralKit.API/Authorization/OwnSystemRequirement.cs +++ b/PluralKit.API/Authorization/OwnSystemRequirement.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Authorization; -namespace PluralKit.API -{ - public class OwnSystemRequirement: IAuthorizationRequirement { } -} \ No newline at end of file +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 index 32e7b5ff..30cf5d05 100644 --- a/PluralKit.API/Authorization/PrivacyRequirement.cs +++ b/PluralKit.API/Authorization/PrivacyRequirement.cs @@ -1,18 +1,15 @@ -using System; - using Microsoft.AspNetCore.Authorization; using PluralKit.Core; -namespace PluralKit.API -{ - public class PrivacyRequirement: IAuthorizationRequirement - { - public readonly Func Mapper; +namespace PluralKit.API; - public PrivacyRequirement(Func mapper) - { - Mapper = mapper; - } +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 index 03e4cb7a..08c3a434 100644 --- a/PluralKit.API/Authorization/SystemOwnerHandler.cs +++ b/PluralKit.API/Authorization/SystemOwnerHandler.cs @@ -1,20 +1,17 @@ -using System.Threading.Tasks; - using Microsoft.AspNetCore.Authorization; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public class SystemOwnerHandler: AuthorizationHandler { - public class SystemOwnerHandler: AuthorizationHandler + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + OwnSystemRequirement requirement, PKSystem resource) { - 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; - } + 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 index 5661dc7e..8e7cccea 100644 --- a/PluralKit.API/Authorization/SystemPrivacyHandler.cs +++ b/PluralKit.API/Authorization/SystemPrivacyHandler.cs @@ -1,21 +1,18 @@ -using System.Threading.Tasks; - using Microsoft.AspNetCore.Authorization; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public class SystemPrivacyHandler: AuthorizationHandler, PKSystem> { - public class SystemPrivacyHandler: AuthorizationHandler, PKSystem> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + PrivacyRequirement requirement, PKSystem resource) { - 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; - } + 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 index a9a3e8d8..10f18c16 100644 --- a/PluralKit.API/Controllers/PKControllerBase.cs +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -1,100 +1,92 @@ -using System; -using System.Net; using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; - -using NodaTime; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public class PKControllerBase: ControllerBase { - 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}$"); + + protected readonly ApiConfig _config; + protected readonly IDatabase _db; + protected readonly ModelRepository _repo; + protected readonly DispatchService _dispatch; + + public PKControllerBase(IServiceProvider svc) { - private readonly Guid _requestId = Guid.NewGuid(); - private readonly Regex _shortIdRegex = new Regex("^[a-z]{5}$"); - private readonly Regex _snowflakeRegex = new Regex("^[0-9]{17,19}$"); + _config = svc.GetRequiredService(); + _db = svc.GetRequiredService(); + _repo = svc.GetRequiredService(); + _dispatch = svc.GetRequiredService(); + } - 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 Task ResolveMember(string memberRef) - { - if (Guid.TryParse(memberRef, out var guid)) - return _repo.GetMemberByGuid(guid); - - if (_shortIdRegex.IsMatch(memberRef)) - return _repo.GetMemberByHid(memberRef); - - return Task.FromResult(null); - } - - protected Task ResolveGroup(string groupRef) - { - if (Guid.TryParse(groupRef, out var guid)) - return _repo.GetGroupByGuid(guid); - - if (_shortIdRegex.IsMatch(groupRef)) - return _repo.GetGroupByHid(groupRef); - - return Task.FromResult(null); - } - - protected LookupContext ContextFor(PKSystem system) + protected Task ResolveSystem(string systemRef) + { + if (systemRef == "@me") { HttpContext.Items.TryGetValue("SystemId", out var systemId); - if (systemId == null) return LookupContext.ByNonOwner; - return ((SystemId)systemId) == system.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner; + if (systemId == null) + throw Errors.GenericAuthError; + return _repo.GetSystem((SystemId)systemId); } - 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; - } + if (Guid.TryParse(systemRef, out var guid)) + return _repo.GetSystemByGuid(guid); - 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; - } + if (_snowflakeRegex.IsMatch(systemRef)) + return _repo.GetSystemByAccount(ulong.Parse(systemRef)); + + if (_shortIdRegex.IsMatch(systemRef)) + return _repo.GetSystemByHid(systemRef); + + return Task.FromResult(null); + } + + protected Task ResolveMember(string memberRef) + { + if (Guid.TryParse(memberRef, out var guid)) + return _repo.GetMemberByGuid(guid); + + if (_shortIdRegex.IsMatch(memberRef)) + return _repo.GetMemberByHid(memberRef); + + return Task.FromResult(null); + } + + protected Task ResolveGroup(string groupRef) + { + if (Guid.TryParse(groupRef, out var guid)) + return _repo.GetGroupByGuid(guid); + + if (_shortIdRegex.IsMatch(groupRef)) + return _repo.GetGroupByHid(groupRef); + + return Task.FromResult(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/v1/AccountController.cs b/PluralKit.API/Controllers/v1/AccountController.cs index 4d9407da..48b08b2a 100644 --- a/PluralKit.API/Controllers/v1/AccountController.cs +++ b/PluralKit.API/Controllers/v1/AccountController.cs @@ -1,34 +1,32 @@ -using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("1.0")] +[Route("v{version:apiVersion}/a")] +public class AccountController: ControllerBase { - [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) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - public AccountController(IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - } + _db = db; + _repo = repo; + } - [HttpGet("{aid}")] - public async Task> GetSystemByAccount(ulong aid) - { - var system = await _repo.GetSystemByAccount(aid); - if (system == null) - return NotFound("Account not found."); + [HttpGet("{aid}")] + public async Task> GetSystemByAccount(ulong aid) + { + var system = await _repo.GetSystemByAccount(aid); + if (system == null) + return NotFound("Account not found."); - return Ok(system.ToJson(User.ContextFor(system))); - } + return Ok(system.ToJson(User.ContextFor(system))); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/MemberController.cs b/PluralKit.API/Controllers/v1/MemberController.cs index 5b7f5512..e40d73f8 100644 --- a/PluralKit.API/Controllers/v1/MemberController.cs +++ b/PluralKit.API/Controllers/v1/MemberController.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading.Tasks; - using Dapper; using Microsoft.AspNetCore.Authorization; @@ -10,117 +7,115 @@ using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("1.0")] +[Route("v{version:apiVersion}/m")] +public class MemberController: ControllerBase { - [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) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly IAuthorizationService _auth; + _auth = auth; + _db = db; + _repo = repo; + } - public MemberController(IAuthorizationService auth, IDatabase db, ModelRepository repo) + [HttpGet("{hid}")] + public async Task> GetMember(string hid) + { + var member = await _repo.GetMemberByHid(hid); + if (member == null) return NotFound("Member not found."); + + return Ok(member.ToJson(User.ContextFor(member), true)); + } + + [HttpPost] + [Authorize] + public async Task> PostMember([FromBody] JObject properties) + { + if (!properties.ContainsKey("name")) + return BadRequest("Member name must be specified."); + + var systemId = User.CurrentSystem(); + var systemData = await _repo.GetSystem(systemId); + + await using var conn = await _db.Obtain(); + + // 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})."); + + await using var tx = await conn.BeginTransactionAsync(); + var member = await _repo.CreateMember(systemId, properties.Value("name"), conn); + + var patch = MemberPatch.FromJSON(properties); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) { - _auth = auth; - _db = db; - _repo = repo; + await tx.RollbackAsync(); + + var err = patch.Errors[0]; + if (err is FieldTooLongError) + return BadRequest($"Field {err.Key} is too long " + + $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength})."); + if (err.Text != null) + return BadRequest(err.Text); + return BadRequest($"Field {err.Key} is invalid."); } - [HttpGet("{hid}")] - public async Task> GetMember(string hid) - { - var member = await _repo.GetMemberByHid(hid); - if (member == null) return NotFound("Member not found."); + member = await _repo.UpdateMember(member.Id, patch, conn); + await tx.CommitAsync(); + return Ok(member.ToJson(User.ContextFor(member), true)); + } - return Ok(member.ToJson(User.ContextFor(member), needsLegacyProxyTags: true)); + [HttpPatch("{hid}")] + [Authorize] + public async Task> PatchMember(string hid, [FromBody] JObject changes) + { + var member = await _repo.GetMemberByHid(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."); + + var patch = MemberPatch.FromJSON(changes); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + { + var err = patch.Errors[0]; + if (err is FieldTooLongError) + return BadRequest($"Field {err.Key} is too long " + + $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength})."); + if (err.Text != null) + return BadRequest(err.Text); + return BadRequest($"Field {err.Key} is invalid."); } - [HttpPost] - [Authorize] - public async Task> PostMember([FromBody] JObject properties) - { - if (!properties.ContainsKey("name")) - return BadRequest("Member name must be specified."); + var newMember = await _repo.UpdateMember(member.Id, patch); + return Ok(newMember.ToJson(User.ContextFor(newMember), true)); + } - var systemId = User.CurrentSystem(); - var systemData = await _repo.GetSystem(systemId); + [HttpDelete("{hid}")] + [Authorize] + public async Task DeleteMember(string hid) + { + var member = await _repo.GetMemberByHid(hid); + if (member == null) return NotFound("Member not found."); - await using var conn = await _db.Obtain(); + var res = await _auth.AuthorizeAsync(User, member, "EditMember"); + if (!res.Succeeded) return Unauthorized($"Member '{hid}' is not part of your system."); - // 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})."); - - await using var tx = await conn.BeginTransactionAsync(); - var member = await _repo.CreateMember(systemId, properties.Value("name"), conn); - - var patch = MemberPatch.FromJSON(properties); - - patch.AssertIsValid(); - if (patch.Errors.Count > 0) - { - await tx.RollbackAsync(); - - var err = patch.Errors[0]; - if (err is FieldTooLongError) - return BadRequest($"Field {err.Key} is too long " - + $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength})."); - else if (err.Text != null) - return BadRequest(err.Text); - else - return BadRequest($"Field {err.Key} is invalid."); - } - - member = await _repo.UpdateMember(member.Id, patch, conn); - await tx.CommitAsync(); - return Ok(member.ToJson(User.ContextFor(member), needsLegacyProxyTags: true)); - } - - [HttpPatch("{hid}")] - [Authorize] - public async Task> PatchMember(string hid, [FromBody] JObject changes) - { - var member = await _repo.GetMemberByHid(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."); - - var patch = MemberPatch.FromJSON(changes); - - patch.AssertIsValid(); - if (patch.Errors.Count > 0) - { - var err = patch.Errors[0]; - if (err is FieldTooLongError) - return BadRequest($"Field {err.Key} is too long " - + $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength})."); - else if (err.Text != null) - return BadRequest(err.Text); - else - return BadRequest($"Field {err.Key} is invalid."); - } - - var newMember = await _repo.UpdateMember(member.Id, patch); - return Ok(newMember.ToJson(User.ContextFor(newMember), needsLegacyProxyTags: true)); - } - - [HttpDelete("{hid}")] - [Authorize] - public async Task DeleteMember(string hid) - { - var member = await _repo.GetMemberByHid(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(member.Id); - return Ok(); - } + await _repo.DeleteMember(member.Id); + return Ok(); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/MessageController.cs b/PluralKit.API/Controllers/v1/MessageController.cs index 00e39e01..9e51e57a 100644 --- a/PluralKit.API/Controllers/v1/MessageController.cs +++ b/PluralKit.API/Controllers/v1/MessageController.cs @@ -1,37 +1,31 @@ -using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using NodaTime; - using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("1.0")] +[Route("v{version:apiVersion}/msg")] +public class MessageController: ControllerBase { - [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) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _repo = repo; + _db = db; + } - 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."); - [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 msg.ToJson(User.ContextFor(msg.System), APIVersion.V1); - } + return msg.ToJson(User.ContextFor(msg.System), APIVersion.V1); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/MetaController.cs b/PluralKit.API/Controllers/v1/MetaController.cs index 0ac04974..f0fa4097 100644 --- a/PluralKit.API/Controllers/v1/MetaController.cs +++ b/PluralKit.API/Controllers/v1/MetaController.cs @@ -1,40 +1,35 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -using System.Linq; - using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("1.0")] +[Route("v{version:apiVersion}")] +public class MetaController: ControllerBase { - [ApiController] - [ApiVersion("1.0")] - [Route("v{version:apiVersion}")] - public class MetaController: ControllerBase + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public MetaController(IDatabase db, ModelRepository repo) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - public MetaController(IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - } + _db = db; + _repo = repo; + } - [HttpGet("meta")] - public async Task> GetMeta() - { - await using var conn = await _db.Obtain(); - var shards = await _repo.GetShards(); + [HttpGet("meta")] + public async Task> GetMeta() + { + await using var conn = await _db.Obtain(); + var shards = await _repo.GetShards(); - var o = new JObject(); - o.Add("shards", shards.ToJSON()); - o.Add("version", BuildInfoService.Version); + var o = new JObject(); + o.Add("shards", shards.ToJSON()); + o.Add("version", BuildInfoService.Version); - return Ok(o); - } + return Ok(o); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/SystemController.cs b/PluralKit.API/Controllers/v1/SystemController.cs index 437d4994..51fa6233 100644 --- a/PluralKit.API/Controllers/v1/SystemController.cs +++ b/PluralKit.API/Controllers/v1/SystemController.cs @@ -1,12 +1,6 @@ -using System; -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; @@ -16,183 +10,189 @@ using NodaTime; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public struct SwitchesReturn { - 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 Instant? Timestamp { get; set; } + 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) { - [JsonProperty("timestamp")] public Instant Timestamp { get; set; } - [JsonProperty("members")] public IEnumerable Members { get; set; } + _db = db; + _auth = auth; + _repo = repo; } - public struct FrontersReturn + [HttpGet] + [Authorize] + public async Task> GetOwnSystem() { - [JsonProperty("timestamp")] public Instant Timestamp { get; set; } - [JsonProperty("members")] public IEnumerable Members { get; set; } + var system = await _repo.GetSystem(User.CurrentSystem()); + return system.ToJson(User.ContextFor(system)); } - public struct PostSwitchParams + [HttpGet("{hid}")] + public async Task> GetSystem(string hid) { - public Instant? Timestamp { get; set; } - public ICollection Members { get; set; } + var system = await _repo.GetSystemByHid(hid); + if (system == null) return NotFound("System not found."); + return Ok(system.ToJson(User.ContextFor(system))); } - [ApiController] - [ApiVersion("1.0")] - [Route("v{version:apiVersion}/s")] - public class SystemController: ControllerBase + [HttpGet("{hid}/members")] + public async Task>> GetMembers(string hid) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly IAuthorizationService _auth; + var system = await _repo.GetSystemByHid(hid); + if (system == null) + return NotFound("System not found."); - public SystemController(IDatabase db, IAuthorizationService auth, ModelRepository repo) + if (!system.MemberListPrivacy.CanAccess(User.ContextFor(system))) + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized to view member list."); + + var members = _repo.GetSystemMembers(system.Id); + return Ok(await members + .Where(m => m.MemberVisibility.CanAccess(User.ContextFor(system))) + .Select(m => m.ToJson(User.ContextFor(system), needsLegacyProxyTags: true)) + .ToListAsync()); + } + + [HttpGet("{hid}/switches")] + public async Task>> GetSwitches( + string hid, [FromQuery(Name = "before")] Instant? before) + { + if (before == null) before = SystemClock.Instance.GetCurrentInstant(); + + var system = await _repo.GetSystemByHid(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 _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 100;", + new { System = system.Id, Before = before } + )); + + return Ok(res); + } + + [HttpGet("{hid}/fronters")] + public async Task> GetFronters(string hid) + { + var system = await _repo.GetSystemByHid(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(system.Id); + if (sw == null) return NotFound("System has no registered switches."); + + var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); + return Ok(new FrontersReturn { - _db = db; - _auth = auth; - _repo = repo; + Timestamp = sw.Timestamp, + Members = await members.Select(m => m.ToJson(User.ContextFor(system), true)).ToListAsync() + }); + } + + [HttpPatch] + [Authorize] + public async Task> EditSystem([FromBody] JObject changes) + { + var system = await _repo.GetSystem(User.CurrentSystem()); + + var patch = SystemPatch.FromJSON(changes); + + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + { + var err = patch.Errors[0]; + if (err is FieldTooLongError) + return BadRequest($"Field {err.Key} is too long " + + $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength})."); + + return BadRequest($"Field {err.Key} is invalid."); } - [HttpGet] - [Authorize] - public async Task> GetOwnSystem() + system = await _repo.UpdateSystem(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(User.CurrentSystem()); + if (latestSwitch != null) { - var system = await _repo.GetSystem(User.CurrentSystem()); - return system.ToJson(User.ContextFor(system)); + 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."); } - [HttpGet("{hid}")] - public async Task> GetSystem(string hid) + // 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) { - var system = await _repo.GetSystemByHid(hid); - if (system == null) return NotFound("System not found."); - return Ok(system.ToJson(User.ContextFor(system))); + if (!membersDict.TryGetValue(givenMemberId, out var member)) + return BadRequest($"Member '{givenMemberId}' not found."); + membersInOrder.Add(member); } - [HttpGet("{hid}/members")] - public async Task>> GetMembers(string hid) - { - var system = await _repo.GetSystemByHid(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 = _repo.GetSystemMembers(system.Id); - return Ok(await members - .Where(m => m.MemberVisibility.CanAccess(User.ContextFor(system))) - .Select(m => m.ToJson(User.ContextFor(system), needsLegacyProxyTags: true)) - .ToListAsync()); - } - - [HttpGet("{hid}/switches")] - public async Task>> GetSwitches(string hid, [FromQuery(Name = "before")] Instant? before) - { - if (before == null) before = SystemClock.Instance.GetCurrentInstant(); - - var system = await _repo.GetSystemByHid(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 _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 100;", new { System = system.Id, Before = before })); - return Ok(res); - } - - [HttpGet("{hid}/fronters")] - public async Task> GetFronters(string hid) - { - var system = await _repo.GetSystemByHid(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(system.Id); - if (sw == null) return NotFound("System has no registered switches."); - - var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); - return Ok(new FrontersReturn - { - Timestamp = sw.Timestamp, - Members = await members.Select(m => m.ToJson(User.ContextFor(system), needsLegacyProxyTags: true)).ToListAsync() - }); - } - - [HttpPatch] - [Authorize] - public async Task> EditSystem([FromBody] JObject changes) - { - var system = await _repo.GetSystem(User.CurrentSystem()); - - var patch = SystemPatch.FromJSON(changes); - - patch.AssertIsValid(); - if (patch.Errors.Count > 0) - { - var err = patch.Errors[0]; - if (err is FieldTooLongError) - return BadRequest($"Field {err.Key} is too long " - + $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength})."); - - return BadRequest($"Field {err.Key} is invalid."); - } - - system = await _repo.UpdateSystem(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(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(); - } + // 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 index 6e604302..d636fa64 100644 --- a/PluralKit.API/Controllers/v2/DiscordControllerV2.cs +++ b/PluralKit.API/Controllers/v2/DiscordControllerV2.cs @@ -1,136 +1,134 @@ -using System; -using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; -using NodaTime; - using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("2.0")] +[Route("v{version:apiVersion}")] +public class DiscordControllerV2: PKControllerBase { - [ApiController] - [ApiVersion("2.0")] - [Route("v{version:apiVersion}")] - public class DiscordControllerV2: PKControllerBase + public DiscordControllerV2(IServiceProvider svc) : base(svc) { } + + + [HttpGet("systems/@me/guilds/{guild_id}")] + public async Task SystemGuildGet(ulong guild_id) { - public DiscordControllerV2(IServiceProvider svc) : base(svc) { } + var system = await ResolveSystem("@me"); + var settings = await _repo.GetSystemGuild(guild_id, system.Id, false); + if (settings == null) + throw Errors.SystemGuildNotFound; + PKMember member = null; + if (settings.AutoproxyMember != null) + member = await _repo.GetMember(settings.AutoproxyMember.Value); - [HttpGet("systems/@me/guilds/{guild_id}")] - public async Task SystemGuildGet(ulong guild_id) + return Ok(settings.ToJson(member?.Hid)); + } + + [HttpPatch("systems/@me/guilds/{guild_id}")] + public async Task DoSystemGuildPatch(ulong guild_id, [FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + var settings = await _repo.GetSystemGuild(guild_id, system.Id, false); + if (settings == null) + throw Errors.SystemGuildNotFound; + + MemberId? memberId = null; + if (data.ContainsKey("autoproxy_member")) { - var system = await ResolveSystem("@me"); - var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false); - if (settings == null) - throw Errors.SystemGuildNotFound; - - PKMember member = null; - if (settings.AutoproxyMember != null) - member = await _repo.GetMember(settings.AutoproxyMember.Value); - - return Ok(settings.ToJson(member?.Hid)); - } - - [HttpPatch("systems/@me/guilds/{guild_id}")] - public async Task DoSystemGuildPatch(ulong guild_id, [FromBody] JObject data) - { - var system = await ResolveSystem("@me"); - var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false); - if (settings == null) - throw Errors.SystemGuildNotFound; - - MemberId? memberId = null; - if (data.ContainsKey("autoproxy_member")) + if (data["autoproxy_member"].Type != JTokenType.Null) { - if (data["autoproxy_member"].Type != JTokenType.Null) - { - var member = await ResolveMember(data.Value("autoproxy_member")); - if (member == null) - throw Errors.MemberNotFound; + var member = await ResolveMember(data.Value("autoproxy_member")); + if (member == null) + throw Errors.MemberNotFound; - memberId = member.Id; - } + memberId = member.Id; } - else - memberId = settings.AutoproxyMember; + } + else + { + memberId = settings.AutoproxyMember; + } - var patch = SystemGuildPatch.FromJson(data, memberId); + var patch = SystemGuildPatch.FromJson(data, memberId); - patch.AssertIsValid(); - if (patch.Errors.Count > 0) - throw new ModelParseError(patch.Errors); + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); - // this is less than great, but at least it's legible - if (patch.AutoproxyMember.Value == null) - if (patch.AutoproxyMode.IsPresent) - { - if (patch.AutoproxyMode.Value == AutoproxyMode.Member) - throw Errors.MissingAutoproxyMember; - } - else if (settings.AutoproxyMode == AutoproxyMode.Member) + // this is less than great, but at least it's legible + if (patch.AutoproxyMember.Value == null) + if (patch.AutoproxyMode.IsPresent) + { + if (patch.AutoproxyMode.Value == AutoproxyMode.Member) throw Errors.MissingAutoproxyMember; + } + else if (settings.AutoproxyMode == AutoproxyMode.Member) + { + throw Errors.MissingAutoproxyMember; + } - var newSettings = await _repo.UpdateSystemGuild(system.Id, guild_id, patch); + var newSettings = await _repo.UpdateSystemGuild(system.Id, guild_id, patch); - PKMember? newMember = null; - if (newSettings.AutoproxyMember != null) - newMember = await _repo.GetMember(newSettings.AutoproxyMember.Value); - return Ok(newSettings.ToJson(newMember?.Hid)); - } + PKMember? newMember = null; + if (newSettings.AutoproxyMember != null) + newMember = await _repo.GetMember(newSettings.AutoproxyMember.Value); + return Ok(newSettings.ToJson(newMember?.Hid)); + } - [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; + [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, defaultInsert: false); - if (settings == null) - throw Errors.MemberGuildNotFound; + var settings = await _repo.GetMemberGuild(guild_id, member.Id, false); + if (settings == null) + throw Errors.MemberGuildNotFound; - return Ok(settings.ToJson()); - } + 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; + [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, defaultInsert: false); - if (settings == null) - throw Errors.MemberGuildNotFound; + var settings = await _repo.GetMemberGuild(guild_id, member.Id, false); + if (settings == null) + throw Errors.MemberGuildNotFound; - var patch = MemberGuildPatch.FromJson(data); + var patch = MemberGuildPatch.FromJson(data); - patch.AssertIsValid(); - if (patch.Errors.Count > 0) - throw new ModelParseError(patch.Errors); + 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()); - } + 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; + [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 = this.ContextFor(msg.System); - return msg.ToJson(ctx, APIVersion.V2); - } + var ctx = ContextFor(msg.System); + return msg.ToJson(ctx, APIVersion.V2); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index 730dc178..91ccafaf 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -1,142 +1,135 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("2.0")] +[Route("v{version:apiVersion}")] +public class GroupControllerV2: PKControllerBase { - [ApiController] - [ApiVersion("2.0")] - [Route("v{version:apiVersion}")] - public class GroupControllerV2: PKControllerBase + public GroupControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("systems/{systemRef}/groups")] + public async Task GetSystemGroups(string systemRef, [FromQuery] bool with_members) { - public GroupControllerV2(IServiceProvider svc) : base(svc) { } + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; - [HttpGet("systems/{systemRef}/groups")] - public async Task GetSystemGroups(string systemRef, [FromQuery] bool with_members) + var ctx = ContextFor(system); + + if (with_members && !system.MemberListPrivacy.CanAccess(ctx)) + throw Errors.UnauthorizedMemberList; + + if (!system.GroupListPrivacy.CanAccess(User.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 system = await ResolveSystem(systemRef); - if (system == null) - throw Errors.SystemNotFound; + var q = await _repo.GetGroupMemberInfo(await groups.Select(x => x.Id).ToListAsync()); - var ctx = this.ContextFor(system); - - if (with_members && !system.MemberListPrivacy.CanAccess(ctx)) - throw Errors.UnauthorizedMemberList; - - if (!system.GroupListPrivacy.CanAccess(User.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); + foreach (var row in q) + if (row.MemberVisibility.CanAccess(ctx)) + ((JArray)j_groups.Find(x => x.Value("id") == row.Group)["members"]).Add(row.MemberUuid); } - [HttpPost("groups")] - public async Task GroupCreate([FromBody] JObject data) + return Ok(j_groups); + } + + [HttpPost("groups")] + public async Task GroupCreate([FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + + // Check group cap + var existingGroupCount = await _repo.GetSystemGroupCount(system.Id); + var groupLimit = system.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() { - var system = await ResolveSystem("@me"); + Event = DispatchEvent.CREATE_GROUP, + EventData = patch.ToJson(), + }); - // Check group cap - var existingGroupCount = await _repo.GetSystemGroupCount(system.Id); - var groupLimit = system.GroupLimitOverride ?? Limits.MaxGroupCount; - if (existingGroupCount >= groupLimit) - throw Errors.GroupLimitReached; + await tx.CommitAsync(); - 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); + return Ok(newGroup.ToJson(LookupContext.ByOwner)); + } - using var conn = await _db.Obtain(); - using var tx = await conn.BeginTransactionAsync(); + [HttpGet("groups/{groupRef}")] + public async Task GroupGet(string groupRef) + { + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; - var newGroup = await _repo.CreateGroup(system.Id, patch.Name.Value, conn); - newGroup = await _repo.UpdateGroup(newGroup.Id, patch, conn); + var system = await _repo.GetSystem(group.System); + return Ok(group.ToJson(ContextFor(group), system.Hid)); + } - _ = _dispatch.Dispatch(newGroup.Id, new UpdateDispatchData() - { - Event = DispatchEvent.CREATE_GROUP, - EventData = patch.ToJson(), - }); + [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); - await tx.CommitAsync(); + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); - return Ok(newGroup.ToJson(LookupContext.ByOwner)); - } + var newGroup = await _repo.UpdateGroup(group.Id, patch); + 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; + [HttpDelete("groups/{groupRef}")] + public async Task GroupDelete(string groupRef) + { + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; - var system = await _repo.GetSystem(group.System); + var system = await ResolveSystem("@me"); + if (system.Id != group.System) + throw Errors.NotOwnGroupError; - return Ok(group.ToJson(this.ContextFor(group), systemStr: system.Hid)); - } + await _repo.DeleteGroup(group.Id); - [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(); - } + return NoContent(); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs index afed262d..3a279ece 100644 --- a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -1,281 +1,272 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using Dapper; - using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("2.0")] +[Route("v{version:apiVersion}")] +public class GroupMemberControllerV2: PKControllerBase { - [ApiController] - [ApiVersion("2.0")] - [Route("v{version:apiVersion}")] - public class GroupMemberControllerV2: PKControllerBase + public GroupMemberControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("groups/{groupRef}/members")] + public async Task GetGroupMembers(string groupRef) { - public GroupMemberControllerV2(IServiceProvider svc) : base(svc) { } + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; - [HttpGet("groups/{groupRef}/members")] - public async Task GetGroupMembers(string groupRef) + 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, v: APIVersion.V2)); + + 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 group = await ResolveGroup(groupRef); - if (group == null) - throw Errors.GroupNotFound; + var memberRef = JmemberRef.Value(); + var member = await ResolveMember(memberRef); - var ctx = this.ContextFor(group); + // todo: have a list of these errors instead of immediately throwing - if (!group.ListPrivacy.CanAccess(ctx)) - throw Errors.UnauthorizedGroupMemberList; + if (member == null) + throw Errors.MemberNotFoundWithRef(memberRef); + if (member.System != system.Id) + throw Errors.NotOwnMemberErrorWithRef(memberRef); - 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, v: APIVersion.V2)); - - return Ok(o); + members.Add(member.Id); } - [HttpPost("groups/{groupRef}/members/add")] - public async Task AddGroupMembers(string groupRef, [FromBody] JArray memberRefs) + 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) { - if (memberRefs.Count == 0) - throw Errors.GenericBadRequest; + var memberRef = JmemberRef.Value(); + var member = await ResolveMember(memberRef); - var system = await ResolveSystem("@me"); + 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); + + 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); + if (group == null) throw Errors.GroupNotFound; if (group.System != system.Id) - throw Errors.NotOwnGroupError; + throw Errors.NotOwnGroupErrorWithRef(groupRef); - var members = new List(); - - foreach (var JmemberRef in memberRefs) - { - var memberRef = JmemberRef.Value(); - var member = await ResolveMember(memberRef); - - // 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(); + groups.Add(group.Id); } - [HttpPost("groups/{groupRef}/members/remove")] - public async Task RemoveGroupMembers(string groupRef, [FromBody] JArray memberRefs) + 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) { - if (memberRefs.Count == 0) - throw Errors.GenericBadRequest; - - var system = await ResolveSystem("@me"); - + var groupRef = JgroupRef.Value(); var group = await ResolveGroup(groupRef); + if (group == null) - throw Errors.GroupNotFound; + throw Errors.GroupNotFoundWithRef(groupRef); if (group.System != system.Id) - throw Errors.NotOwnGroupError; + throw Errors.NotOwnGroupErrorWithRef(groupRef); - var members = new List(); - - foreach (var JmemberRef in memberRefs) - { - 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.Id); - } - - await _repo.RemoveMembersFromGroup(group.Id, members); - - return NoContent(); + groups.Add(group.Id); } - [HttpPost("groups/{groupRef}/members/overwrite")] - public async Task OverwriteGroupMembers(string groupRef, [FromBody] JArray memberRefs) - { - var system = await ResolveSystem("@me"); + 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); + if (group == null) - throw Errors.GroupNotFound; + throw Errors.GroupNotFoundWithRef(groupRef); if (group.System != system.Id) - throw Errors.NotOwnGroupError; + throw Errors.NotOwnGroupErrorWithRef(groupRef); - var members = new List(); - - foreach (var JmemberRef in memberRefs) - { - 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.Id); - } - - await _repo.ClearGroupMembers(group.Id); - - if (members.Count > 0) - await _repo.AddMembersToGroup(group.Id, members); - - return NoContent(); + groups.Add(group.Id); } + await _repo.ClearMemberGroups(member.Id); - [HttpGet("members/{memberRef}/groups")] - public async Task GetMemberGroups(string memberRef) - { - var member = await ResolveMember(memberRef); - var ctx = this.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); - - 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); - - 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); - - 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(); - } + 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 index 966ed588..ec858e96 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -1,122 +1,117 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("2.0")] +[Route("v{version:apiVersion}")] +public class MemberControllerV2: PKControllerBase { - [ApiController] - [ApiVersion("2.0")] - [Route("v{version:apiVersion}")] - public class MemberControllerV2: PKControllerBase + public MemberControllerV2(IServiceProvider svc) : base(svc) { } + + + [HttpGet("systems/{systemRef}/members")] + public async Task GetSystemMembers(string systemRef) { - public MemberControllerV2(IServiceProvider svc) : base(svc) { } + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + var ctx = ContextFor(system); - [HttpGet("systems/{systemRef}/members")] - public async Task GetSystemMembers(string systemRef) + 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, v: APIVersion.V2)) + .ToListAsync()); + } + + [HttpPost("members")] + public async Task MemberCreate([FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + + var memberCount = await _repo.GetSystemMemberCount(system.Id); + var memberLimit = system.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() { - var system = await ResolveSystem(systemRef); - if (system == null) - throw Errors.SystemNotFound; + Event = DispatchEvent.CREATE_MEMBER, + EventData = patch.ToJson(), + }); - var ctx = this.ContextFor(system); + await tx.CommitAsync(); - if (!system.MemberListPrivacy.CanAccess(this.ContextFor(system))) - throw Errors.UnauthorizedMemberList; + return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2)); + } - var members = _repo.GetSystemMembers(system.Id); - return Ok(await members - .Where(m => m.MemberVisibility.CanAccess(ctx)) - .Select(m => m.ToJson(ctx, v: APIVersion.V2)) - .ToListAsync()); - } + [HttpGet("members/{memberRef}")] + public async Task MemberGet(string memberRef) + { + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; - [HttpPost("members")] - public async Task MemberCreate([FromBody] JObject data) - { - var system = await ResolveSystem("@me"); + var system = await _repo.GetSystem(member.System); - var memberCount = await _repo.GetSystemMemberCount(system.Id); - var memberLimit = system.MemberLimitOverride ?? Limits.MaxMemberCount; - if (memberCount >= memberLimit) - throw Errors.MemberLimitReached; + return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid, v: APIVersion.V2)); + } - 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); + [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; - using var conn = await _db.Obtain(); - using var tx = await conn.BeginTransactionAsync(); + var patch = MemberPatch.FromJSON(data, APIVersion.V2); - var newMember = await _repo.CreateMember(system.Id, patch.Name.Value, conn); - newMember = await _repo.UpdateMember(newMember.Id, patch, conn); + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); - _ = _dispatch.Dispatch(newMember.Id, new() - { - Event = DispatchEvent.CREATE_MEMBER, - EventData = patch.ToJson(), - }); + var newMember = await _repo.UpdateMember(member.Id, patch); + return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2)); + } - await tx.CommitAsync(); + [HttpDelete("members/{memberRef}")] + public async Task MemberDelete(string memberRef) + { + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; - return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2)); - } + var system = await ResolveSystem("@me"); + if (system.Id != member.System) + throw Errors.NotOwnMemberError; - [HttpGet("members/{memberRef}")] - public async Task MemberGet(string memberRef) - { - var member = await ResolveMember(memberRef); - if (member == null) - throw Errors.MemberNotFound; + await _repo.DeleteMember(member.Id); - var system = await _repo.GetSystem(member.System); - - return Ok(member.ToJson(this.ContextFor(member), systemStr: system.Hid, v: APIVersion.V2)); - } - - [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, APIVersion.V2); - - 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, v: APIVersion.V2)); - } - - [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(); - } + return NoContent(); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/PrivateControllerV2.cs b/PluralKit.API/Controllers/v2/PrivateControllerV2.cs index ce8c7772..1fdb889e 100644 --- a/PluralKit.API/Controllers/v2/PrivateControllerV2.cs +++ b/PluralKit.API/Controllers/v2/PrivateControllerV2.cs @@ -1,30 +1,26 @@ -using System; -using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("2.0")] +[Route("v{version:apiVersion}")] +public class PrivateControllerV2: PKControllerBase { - [ApiController] - [ApiVersion("2.0")] - [Route("v{version:apiVersion}")] - public class PrivateControllerV2: PKControllerBase + public PrivateControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("meta")] + public async Task> Meta() { - public PrivateControllerV2(IServiceProvider svc) : base(svc) { } + var shards = await _repo.GetShards(); + var stats = await _repo.GetStats(); - [HttpGet("meta")] - public async Task> Meta() - { - var shards = await _repo.GetShards(); - var stats = await _repo.GetStats(); + var o = new JObject(); + o.Add("shards", shards.ToJSON()); + o.Add("stats", stats.ToJson()); - var o = new JObject(); - o.Add("shards", shards.ToJSON()); - o.Add("stats", stats.ToJson()); - - return Ok(o); - } + return Ok(o); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index 69d05457..00bdb932 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -1,254 +1,255 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - using Dapper; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NodaTime; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("2.0")] +[Route("v{version:apiVersion}")] +public class SwitchControllerV2: PKControllerBase { - [ApiController] - [ApiVersion("2.0")] - [Route("v{version:apiVersion}")] - 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) { - public SwitchControllerV2(IServiceProvider svc) : base(svc) { } + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + var ctx = ContextFor(system); - [HttpGet("systems/{systemRef}/switches")] - public async Task GetSystemSwitches(string systemRef, [FromQuery(Name = "before")] Instant? before, [FromQuery(Name = "limit")] int? limit) + 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 { - var system = await ResolveSystem(systemRef); - if (system == null) - throw Errors.SystemNotFound; + Timestamp = sw.Timestamp, + Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync(), + Uuid = sw.Uuid, + }); + } - var ctx = this.ContextFor(system); - if (!system.FrontHistoryPrivacy.CanAccess(ctx)) - throw Errors.UnauthorizedFrontHistory; + [HttpPost("systems/@me/switches")] + public async Task SwitchCreate([FromBody] PostSwitchParams data) + { + if (data.Members.Distinct().Count() != data.Members.Count) + throw Errors.DuplicateMembersInList; - if (before == null) - before = SystemClock.Instance.GetCurrentInstant(); + var system = await ResolveSystem("@me"); - if (limit == null || limit > 100) - limit = 100; + if (data.Timestamp != null && await _repo.GetSwitches(system.Id).Select(x => x.Timestamp) + .ContainsAsync(data.Timestamp.Value)) + throw Errors.SameSwitchTimestampError; - 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); + var members = new List(); + + foreach (var memberRef in data.Members) + { + var member = await ResolveMember(memberRef); + if (member == null) + // todo: which member + throw Errors.MemberNotFound; + if (member.System != system.Id) + throw Errors.NotOwnMemberErrorWithRef(memberRef); + members.Add(member); } - [HttpGet("systems/{systemRef}/fronters")] - public async Task GetSystemFronters(string systemRef) + // 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 system = await ResolveSystem(systemRef); - if (system == null) - throw Errors.SystemNotFound; + var latestSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, latestSwitch.Id)); - var ctx = this.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, v: APIVersion.V2)).ToListAsync(), - Uuid = sw.Uuid, - }); + // 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); - [HttpPost("systems/@me/switches")] - public async Task SwitchCreate([FromBody] PostSwitchParams data) + return Ok(new FrontersReturnNew { - if (data.Members.Distinct().Count() != data.Members.Count) + Uuid = newSwitch.Uuid, + Timestamp = data.Timestamp != null ? data.Timestamp.Value : newSwitch.Timestamp, + Members = members.Select(x => x.ToJson(LookupContext.ByOwner, v: APIVersion.V2)), + }); + } + + + [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, v: APIVersion.V2)).ToListAsync() + }); + } + + [HttpPatch("systems/@me/switches/{switchRef}")] + public async Task SwitchPatch(string switchRef, [FromBody] JObject data) + { + // for now, don't need to make a PatchObject for this, since it's only one param + + 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 system = await ResolveSystem("@me"); + if (system == null) + throw Errors.SystemNotFound; + + 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, v: APIVersion.V2)) + }); + } + + [HttpPatch("systems/@me/switches/{switchRef}/members")] + public async Task SwitchMemberPatch(string switchRef, [FromBody] JArray data) + { + if (!Guid.TryParse(switchRef, out var switchId)) + + if (data.Distinct().Count() != data.Count) throw Errors.DuplicateMembersInList; - var system = await ResolveSystem("@me"); + var system = await ResolveSystem("@me"); - if (data.Timestamp != null && await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(data.Timestamp.Value)) - throw Errors.SameSwitchTimestampError; + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null) + throw Errors.SwitchNotFound; - var members = new List(); + var members = new List(); - foreach (var memberRef in data.Members) - { - var member = await ResolveMember(memberRef); - if (member == null) - // todo: which member - throw Errors.MemberNotFound; - 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, v: APIVersion.V2)), - }); - } - - - [HttpGet("systems/{systemRef}/switches/{switchRef}")] - public async Task SwitchGet(string systemRef, string switchRef) + foreach (var JmemberRef in data) { - if (!Guid.TryParse(switchRef, out var switchId)) - throw Errors.InvalidSwitchId; + var memberRef = JmemberRef.Value(); - var system = await ResolveSystem(systemRef); - if (system == null) - throw Errors.SystemNotFound; + var member = await ResolveMember(memberRef); + if (member == null) + // todo: which member + throw Errors.MemberNotFound; + if (member.System != system.Id) + throw Errors.NotOwnMemberErrorWithRef(memberRef); - var sw = await _repo.GetSwitchByUuid(switchId); - if (sw == null || system.Id != sw.System) - throw Errors.SwitchNotFoundPublic; - - var ctx = this.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, v: APIVersion.V2)).ToListAsync() - }); + members.Add(member); } - [HttpPatch("systems/@me/switches/{switchRef}")] - public async Task SwitchPatch(string switchRef, [FromBody] JObject data) + 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 { - // for now, don't need to make a PatchObject for this, since it's only one param + Uuid = sw.Uuid, + Timestamp = sw.Timestamp, + Members = members.Select(x => x.ToJson(LookupContext.ByOwner, v: APIVersion.V2)) + }); + } - if (!Guid.TryParse(switchRef, out var switchId)) - throw Errors.InvalidSwitchId; + [HttpDelete("systems/@me/switches/{switchRef}")] + public async Task SwitchDelete(string switchRef) + { + 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 ValidationError("timestamp", $"Key 'timestamp' is required.") }); + var system = await ResolveSystem("@me"); + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null || system.Id != sw.System) + throw Errors.SwitchNotFoundPublic; - var value = Instant.FromDateTimeOffset(DateTime.Parse(valueStr).ToUniversalTime()); + await _repo.DeleteSwitch(sw.Id); - var system = await ResolveSystem("@me"); - if (system == null) - throw Errors.SystemNotFound; - - 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, v: APIVersion.V2)), - }); - } - - [HttpPatch("systems/@me/switches/{switchRef}/members")] - public async Task SwitchMemberPatch(string switchRef, [FromBody] JArray data) - { - if (!Guid.TryParse(switchRef, out var switchId)) - - if (data.Distinct().Count() != data.Count) - throw Errors.DuplicateMembersInList; - - var system = await ResolveSystem("@me"); - - 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) - // todo: which member - throw Errors.MemberNotFound; - 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, v: APIVersion.V2)), - }); - } - - [HttpDelete("systems/@me/switches/{switchRef}")] - public async Task SwitchDelete(string switchRef) - { - if (!Guid.TryParse(switchRef, out var switchId)) - throw Errors.InvalidSwitchId; - - var system = await ResolveSystem("@me"); - var sw = await _repo.GetSwitchByUuid(switchId); - if (sw == null || system.Id != sw.System) - throw Errors.SwitchNotFoundPublic; - - await _repo.DeleteSwitch(sw.Id); - - return NoContent(); - } + return NoContent(); } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs index ab730c3a..2c16661f 100644 --- a/PluralKit.API/Controllers/v2/SystemControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -1,41 +1,37 @@ -using System; -using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +[ApiController] +[ApiVersion("2.0")] +[Route("v{version:apiVersion}/systems")] +public class SystemControllerV2: PKControllerBase { - [ApiController] - [ApiVersion("2.0")] - [Route("v{version:apiVersion}/systems")] - public class SystemControllerV2: PKControllerBase + public SystemControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("{systemRef}")] + public async Task SystemGet(string systemRef) { - public SystemControllerV2(IServiceProvider svc) : base(svc) { } + var system = await ResolveSystem(systemRef); + if (system == null) throw Errors.SystemNotFound; + return Ok(system.ToJson(ContextFor(system), APIVersion.V2)); + } - [HttpGet("{systemRef}")] - public async Task SystemGet(string systemRef) - { - var system = await ResolveSystem(systemRef); - if (system == null) throw Errors.SystemNotFound; - else return Ok(system.ToJson(this.ContextFor(system), v: APIVersion.V2)); - } + [HttpPatch("@me")] + public async Task DoSystemPatch([FromBody] JObject data) + { + var system = await ResolveSystem("@me"); + var patch = SystemPatch.FromJSON(data, APIVersion.V2); - [HttpPatch("@me")] - public async Task DoSystemPatch([FromBody] JObject data) - { - var system = await ResolveSystem("@me"); - var patch = SystemPatch.FromJSON(data, APIVersion.V2); + patch.AssertIsValid(); + if (patch.Errors.Count > 0) + throw new ModelParseError(patch.Errors); - 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, v: APIVersion.V2)); - } + var newSystem = await _repo.UpdateSystem(system.Id, patch); + return Ok(newSystem.ToJson(LookupContext.ByOwner, APIVersion.V2)); } } \ No newline at end of file diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index e19e74dc..6e0ad639 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -1,118 +1,131 @@ -using System; -using System.Collections.Generic; - using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API -{ - public class PKError: Exception - { - public int ResponseCode { get; init; } - public int JsonCode { get; init; } - public PKError(int code, int json_code, string message) : base(message) - { - ResponseCode = code; - JsonCode = json_code; - } +namespace PluralKit.API; - public JObject ToJson() - { - var j = new JObject(); - j.Add("message", this.Message); - j.Add("code", this.JsonCode); - return j; - } +public class PKError: Exception +{ + public PKError(int code, int json_code, string message) : base(message) + { + ResponseCode = code; + JsonCode = json_code; } - public class ModelParseError: PKError + public int ResponseCode { get; init; } + public int JsonCode { get; init; } + + public JObject ToJson() { - private IEnumerable _errors { get; init; } - public ModelParseError(IEnumerable errors) : base(400, 40001, "Error parsing JSON model") - { - _errors = errors; - } + var j = new JObject(); + j.Add("message", Message); + j.Add("code", JsonCode); + return j; + } +} - public new JObject ToJson() - { - var j = base.ToJson(); - var e = new JObject(); +public class ModelParseError: PKError +{ + public ModelParseError(IEnumerable errors) : base(400, 40001, "Error parsing JSON model") + { + _errors = errors; + } - foreach (var err in _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) { - 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); + 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."); } - j.Add("errors", e); - return j; + 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 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 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 Unimplemented = new(501, 50001, "Unimplemented"); +} + +public static class APIErrorHandlerExt +{ + public static bool IsUserError(this Exception exc) { - 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 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 Unimplemented = new(501, 50001, "Unimplemented"); - } + // 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; - 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; - // 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 may expanded at some point. - return false; - } + // 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 index 2c9bf0b6..3de763e0 100644 --- a/PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs +++ b/PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs @@ -1,36 +1,32 @@ -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Http; - using Dapper; using PluralKit.Core; -namespace PluralKit.API +namespace PluralKit.API; + +public class AuthorizationTokenHandlerMiddleware { - public class AuthorizationTokenHandlerMiddleware + private readonly RequestDelegate _next; + + public AuthorizationTokenHandlerMiddleware(RequestDelegate next) { - 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) { - _next = next; + 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); } - 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); - } + 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 51d65a08..f25a6989 100644 --- a/PluralKit.API/PluralKit.API.csproj +++ b/PluralKit.API/PluralKit.API.csproj @@ -1,8 +1,9 @@ - net5.0 + net6.0 annotations + enable @@ -11,15 +12,15 @@ $(NoWarn);1591 - full + full - + - <_ContentIncludedByDefault Remove="Properties\launchSettings.json" /> + <_ContentIncludedByDefault Remove="Properties\launchSettings.json"/> @@ -27,16 +28,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/PluralKit.API/Program.cs b/PluralKit.API/Program.cs index a4f3c2ed..5c4f970a 100644 --- a/PluralKit.API/Program.cs +++ b/PluralKit.API/Program.cs @@ -1,36 +1,29 @@ -using System.Threading.Tasks; - using Autofac.Extensions.DependencyInjection; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - using PluralKit.Core; using Serilog; -namespace PluralKit.API -{ - public class Program - { - public static async Task Main(string[] args) - { - InitUtils.InitStatic(); - await BuildInfoService.LoadVersion(); - await CreateHostBuilder(args).Build().RunAsync(); - } +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 async Task Main(string[] args) + { + InitUtils.InitStatic(); + await BuildInfoService.LoadVersion(); + await CreateHostBuilder(args).Build().RunAsync(); } + + 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()); } \ No newline at end of file diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index 05c3a103..e12c79fc 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -1,189 +1,177 @@ -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.Diagnostics; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Versioning; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; using Newtonsoft.Json; -using Serilog; - 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.AddAuthentication("SystemToken") + .AddScheme("SystemToken", null); + + services.AddAuthorization(options => { - Configuration = configuration; - } + options.AddPolicy("EditSystem", + p => p.RequireAuthenticatedUser().AddRequirements(new OwnSystemRequirement())); + options.AddPolicy("EditMember", + p => p.RequireAuthenticatedUser().AddRequirements(new OwnSystemRequirement())); - public IConfiguration Configuration { get; } + 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(); - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) + 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.AddApiVersioning(); + + services.AddVersionedApiExplorer(c => { - services.AddCors(); - services.AddAuthentication("SystemToken") - .AddScheme("SystemToken", null); + c.GroupNameFormat = "'v'VV"; + c.ApiVersionParameterSource = new UrlSegmentApiVersionReader(); + c.SubstituteApiVersionInUrl = true; + }); - services.AddAuthorization(options => + 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) => { - 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) - // 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.AddApiVersioning(); - - services.AddVersionedApiExplorer(c => - { - c.GroupNameFormat = "'v'VV"; - c.ApiVersionParameterSource = new UrlSegmentApiVersionReader(); - c.SubstituteApiVersionInUrl = true; + if (!apiDesc.RelativePath.StartsWith("v1/")) return false; + return apiDesc.GroupName == docName; }); - services.AddSwaggerGen(c => - { - c.SwaggerDoc("v1.0", new OpenApiInfo { Title = "PluralKit", Version = "1.0" }); + // 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(); + } - c.EnableAnnotations(); - c.AddSecurityDefinition("TokenAuth", - new OpenApiSecurityScheme { Name = "Authorization", Type = SecuritySchemeType.ApiKey }); + 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.IsUserError()))); + builder.RegisterModule(new MetricsModule("API")); + builder.RegisterModule(); + builder.RegisterModule(); + } - // 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; - }); + // 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(); - // 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(); + // 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 void ConfigureContainer(ContainerBuilder builder) + // add X-PluralKit-Version header + app.Use((ctx, next) => { - 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.IsUserError()))); - builder.RegisterModule(new MetricsModule("API")); - builder.RegisterModule(); - builder.RegisterModule(); - } + ctx.Response.Headers.Add("X-PluralKit-Version", BuildInfoService.Version); + return next(); + }); - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + app.UseExceptionHandler(handler => handler.Run(async ctx => { - if (env.IsDevelopment()) + var exc = ctx.Features.Get(); + + // 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); } - // add X-PluralKit-Version header - app.Use((ctx, next) => - { - ctx.Response.Headers.Add("X-PluralKit-Version", BuildInfoService.Version); - return next(); - }); + await ctx.Response.CompleteAsync(); + })); - app.UseExceptionHandler(handler => handler.Run(async ctx => - { - var exc = ctx.Features.Get(); + app.UseMiddleware(); - // handle common ISEs that are generated by invalid user input - if (exc.Error.IsUserError()) - { - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsync("{\"message\":\"400: Bad Request\",\"code\":0}"); - } + //app.UseHttpsRedirection(); + app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization")); - 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 - { - var err = (PKError)exc.Error; - ctx.Response.StatusCode = err.ResponseCode; - - var json = JsonConvert.SerializeObject(err.ToJson()); - await ctx.Response.WriteAsync(json); - } - - await ctx.Response.CompleteAsync(); - })); - - app.UseMiddleware(); - - //app.UseHttpsRedirection(); - app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization")); - - app.UseRouting(); - app.UseAuthentication(); - app.UseAuthorization(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); } } \ 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/packages.lock.json b/PluralKit.API/packages.lock.json index dcd71ead..412775ca 100644 --- a/PluralKit.API/packages.lock.json +++ b/PluralKit.API/packages.lock.json @@ -1,1608 +1,1608 @@ { - "version": 1, - "dependencies": { - ".NETCoreApp,Version=v5.0": { - "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" + "version": 1, + "dependencies": { + "net6.0": { + "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.1.0", + "contentHash": "qQTp6o1pKC/L8yKpmUovenlDDw0HNuQ3gdKkq92BbpluEZTJLQ8AiX0NEpevoUgEwL5aHnonHq0E3yOHgoaaIA==", + "dependencies": { + "App.Metrics.Core": "4.1.0", + "App.Metrics.Formatters.Json": "4.1.0" + } + }, + "App.Metrics.Abstractions": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "HolXOB3x6/TQeaHPhMnxYvk5jaFsYgkZ7/OIzjBloRniLz/QE6pW5B7WqyiJ1a1PtCKZmjh/UA1MAB/Dj+eg3Q==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.0.0" + } + }, + "App.Metrics.Concurrency": { + "type": "Transitive", + "resolved": "2.0.1", + "contentHash": "XJ7eYseDig2/S61DygC8XCTckHHKNnGVGR9qTGjdeJ2x3LElKIQuScrhnEuxU3J6pqs0+UMjkATEeE7WsOf87w==", + "dependencies": { + "NETStandard.Library": "1.6.1" + } + }, + "App.Metrics.Core": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "us3u1po1KyPywv/zOqCSXjWZxldWz1yW2zGbRcnsDunv3Sem6M8+DnMYjAnoTplREo9mrm0tuSR5fIwnDg7kUA==", + "dependencies": { + "App.Metrics.Abstractions": "4.1.0", + "App.Metrics.Concurrency": "2.0.1", + "App.Metrics.Formatters.Ascii": "4.1.0", + "Microsoft.CSharp": "4.4.0" + } + }, + "App.Metrics.Formatters.Ascii": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "/OKvOt8AJT9K7EuuXLsTQ6zKmRua4X3NaSxkHZbOAJJ8ouelZGHkAvXRcJlTLoPHiBEW3vbJj/twGsIVC8U3kw==", + "dependencies": { + "App.Metrics.Abstractions": "4.1.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.1.0", + "contentHash": "OCdjSSRIkK0x4dy6NJ8b4H+wVUSAFxqtlL+tBSWNVC79N3K3abLG50NNdeMc79jDNq07M/qb2ow00tsuHiNA0g==", + "dependencies": { + "App.Metrics.Abstractions": "4.1.0", + "System.Text.Json": "4.6.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==" + }, + "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.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": "2.1.0", + "contentHash": "BpMaoBxdXr5VD0yk7rYN6R8lAU9X9JbvsPveNdKT+llIn3J5s4sxpWqaSG/NnzTzTLU5eJE5nrecTl7clg/7dQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.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": "2.0.0", + "contentHash": "Y/lGICwO27fCkQRK3tZseVzFjZaxfGmui990E67sB4MuiPzdJHnJDS/BeYWrHShSSBgCl4KyKRx4ux686fftPg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.0.0", + "Microsoft.Extensions.Configuration.Binder": "2.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.0.0", + "Microsoft.Extensions.Options": "2.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "3.1.10", + "contentHash": "YDuQS3BeaVY6PCWUm5f6qFTYsxhwntQrcfwUzbohU/0rZBL5XI+UsD5SgggHKHX+rFY4laaT428q608Sw/mDsw==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "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.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "NETStandard.Library": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.Win32.Primitives": "4.3.0", + "System.AppContext": "4.3.0", + "System.Collections": "4.3.0", + "System.Collections.Concurrent": "4.3.0", + "System.Console": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tools": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Calendars": "4.3.0", + "System.IO": "4.3.0", + "System.IO.Compression": "4.3.0", + "System.IO.Compression.ZipFile": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Linq": "4.3.0", + "System.Linq.Expressions": "4.3.0", + "System.Net.Http": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Net.Sockets": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Security.Cryptography.X509Certificates": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Text.Encoding.Extensions": "4.3.0", + "System.Text.RegularExpressions": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "System.Threading.Timer": "4.3.0", + "System.Xml.ReaderWriter": "4.3.0", + "System.Xml.XDocument": "4.3.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" + } + }, + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" + }, + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" + }, + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" + }, + "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" + } + }, + "runtime.native.System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.Security.Cryptography.Apple": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", + "dependencies": { + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" + } + }, + "runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", + "dependencies": { + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" + }, + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" + }, + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" + }, + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" + }, + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" + }, + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" + }, + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" + }, + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" + }, + "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" + } + }, + "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.3.0", + "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", + "dependencies": { + "System.Runtime": "4.3.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.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.Tools": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.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.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.Globalization.Calendars": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "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.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Buffers": "4.3.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.IO.Compression": "4.3.0" + } + }, + "System.IO.Compression.ZipFile": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", + "dependencies": { + "System.Buffers": "4.3.0", + "System.IO": "4.3.0", + "System.IO.Compression": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.IO.FileSystem": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.FileSystem.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Linq": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==" + }, + "System.Linq.Expressions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Linq": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Emit.Lightweight": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Reflection.TypeExtensions": "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.Net.Http": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.DiagnosticSource": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Extensions": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.OpenSsl": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Security.Cryptography.X509Certificates": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.Net.Http": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Net.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Net.Sockets": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.ObjectModel": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.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.Emit": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "VR4kk8XLKebQ4MZuKuIni/7oh+QGFmZW3qORd1GvBq/8026OpW501SzT/oypwiQl4TvT8ErnReh/NzY9u+C6wQ==" + }, + "System.Reflection.Emit.ILGeneration": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.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.3.0", + "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.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.Runtime.Numerics": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", + "dependencies": { + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.Apple": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Security.Cryptography.Csp": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.IO": "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.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Collections.Concurrent": "4.3.0", + "System.Linq": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", + "dependencies": { + "System.Collections": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", + "dependencies": { + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Calendars": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Cng": "4.3.0", + "System.Security.Cryptography.Csp": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.OpenSsl": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.Net.Http": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "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.3.0", + "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", + "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.Text.Json": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "4F8Xe+JIkVoDJ8hDAZ7HqLkjctN/6WItJIzQaifBwClC7wmoLSda/Sv2i6i1kycqDb3hWF4JCVbpAweyOKHEUA==" + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "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.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Timer": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Xml.ReaderWriter": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Text.Encoding.Extensions": "4.3.0", + "System.Text.RegularExpressions": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "System.Threading.Tasks.Extensions": "4.3.0" + } + }, + "System.Xml.XDocument": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tools": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "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.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Xml.ReaderWriter": "4.3.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", + "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", + "System.Interactive.Async": "5.0.0" + } + } } - }, - "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.1.0", - "contentHash": "qQTp6o1pKC/L8yKpmUovenlDDw0HNuQ3gdKkq92BbpluEZTJLQ8AiX0NEpevoUgEwL5aHnonHq0E3yOHgoaaIA==", - "dependencies": { - "App.Metrics.Core": "4.1.0", - "App.Metrics.Formatters.Json": "4.1.0" - } - }, - "App.Metrics.Abstractions": { - "type": "Transitive", - "resolved": "4.1.0", - "contentHash": "HolXOB3x6/TQeaHPhMnxYvk5jaFsYgkZ7/OIzjBloRniLz/QE6pW5B7WqyiJ1a1PtCKZmjh/UA1MAB/Dj+eg3Q==", - "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "1.0.0" - } - }, - "App.Metrics.Concurrency": { - "type": "Transitive", - "resolved": "2.0.1", - "contentHash": "XJ7eYseDig2/S61DygC8XCTckHHKNnGVGR9qTGjdeJ2x3LElKIQuScrhnEuxU3J6pqs0+UMjkATEeE7WsOf87w==", - "dependencies": { - "NETStandard.Library": "1.6.1" - } - }, - "App.Metrics.Core": { - "type": "Transitive", - "resolved": "4.1.0", - "contentHash": "us3u1po1KyPywv/zOqCSXjWZxldWz1yW2zGbRcnsDunv3Sem6M8+DnMYjAnoTplREo9mrm0tuSR5fIwnDg7kUA==", - "dependencies": { - "App.Metrics.Abstractions": "4.1.0", - "App.Metrics.Concurrency": "2.0.1", - "App.Metrics.Formatters.Ascii": "4.1.0", - "Microsoft.CSharp": "4.4.0" - } - }, - "App.Metrics.Formatters.Ascii": { - "type": "Transitive", - "resolved": "4.1.0", - "contentHash": "/OKvOt8AJT9K7EuuXLsTQ6zKmRua4X3NaSxkHZbOAJJ8ouelZGHkAvXRcJlTLoPHiBEW3vbJj/twGsIVC8U3kw==", - "dependencies": { - "App.Metrics.Abstractions": "4.1.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.1.0", - "contentHash": "OCdjSSRIkK0x4dy6NJ8b4H+wVUSAFxqtlL+tBSWNVC79N3K3abLG50NNdeMc79jDNq07M/qb2ow00tsuHiNA0g==", - "dependencies": { - "App.Metrics.Abstractions": "4.1.0", - "System.Text.Json": "4.6.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==" - }, - "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.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": "2.1.0", - "contentHash": "BpMaoBxdXr5VD0yk7rYN6R8lAU9X9JbvsPveNdKT+llIn3J5s4sxpWqaSG/NnzTzTLU5eJE5nrecTl7clg/7dQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", - "Microsoft.Extensions.FileProviders.Abstractions": "2.1.0", - "Microsoft.Extensions.Logging.Abstractions": "2.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": "2.0.0", - "contentHash": "Y/lGICwO27fCkQRK3tZseVzFjZaxfGmui990E67sB4MuiPzdJHnJDS/BeYWrHShSSBgCl4KyKRx4ux686fftPg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "2.0.0", - "Microsoft.Extensions.Configuration.Binder": "2.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "2.0.0", - "Microsoft.Extensions.Options": "2.0.0" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "3.1.10", - "contentHash": "YDuQS3BeaVY6PCWUm5f6qFTYsxhwntQrcfwUzbohU/0rZBL5XI+UsD5SgggHKHX+rFY4laaT428q608Sw/mDsw==" - }, - "Microsoft.NETCore.Platforms": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" - }, - "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.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "NETStandard.Library": { - "type": "Transitive", - "resolved": "1.6.1", - "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.Win32.Primitives": "4.3.0", - "System.AppContext": "4.3.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Console": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.Compression.ZipFile": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.Net.Http": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Net.Sockets": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Timer": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0", - "System.Xml.XDocument": "4.3.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" - } - }, - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" - }, - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" - }, - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" - }, - "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" - } - }, - "runtime.native.System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", - "dependencies": { - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" - } - }, - "runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", - "dependencies": { - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" - }, - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" - }, - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" - }, - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" - }, - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" - }, - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" - }, - "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" - } - }, - "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.3.0", - "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", - "dependencies": { - "System.Runtime": "4.3.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.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.Tools": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.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.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.Globalization.Calendars": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "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.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Buffers": "4.3.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.IO.Compression": "4.3.0" - } - }, - "System.IO.Compression.ZipFile": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", - "dependencies": { - "System.Buffers": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.IO.FileSystem": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.FileSystem.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Linq": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Linq.Async": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==" - }, - "System.Linq.Expressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Linq": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Emit.Lightweight": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "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.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.DiagnosticSource": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Net.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Net.Sockets": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.ObjectModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.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.Emit": { - "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "VR4kk8XLKebQ4MZuKuIni/7oh+QGFmZW3qORd1GvBq/8026OpW501SzT/oypwiQl4TvT8ErnReh/NzY9u+C6wQ==" - }, - "System.Reflection.Emit.ILGeneration": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.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.3.0", - "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.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.Runtime.Numerics": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", - "dependencies": { - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.Apple": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Cng": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Security.Cryptography.Csp": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "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.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Linq": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", - "dependencies": { - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Cng": "4.3.0", - "System.Security.Cryptography.Csp": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "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.3.0", - "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", - "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.Text.Json": { - "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "4F8Xe+JIkVoDJ8hDAZ7HqLkjctN/6WItJIzQaifBwClC7wmoLSda/Sv2i6i1kycqDb3hWF4JCVbpAweyOKHEUA==" - }, - "System.Text.RegularExpressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "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.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Timer": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Xml.ReaderWriter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Tasks.Extensions": "4.3.0" - } - }, - "System.Xml.XDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "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.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.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", - "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", - "System.Interactive.Async": "5.0.0" - } - } } - } } \ No newline at end of file diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index b6f47e68..f631bc3c 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,17 +1,10 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; using App.Metrics; using Autofac; using Myriad.Cache; -using Myriad.Extensions; using Myriad.Gateway; using Myriad.Rest; using Myriad.Types; @@ -25,267 +18,261 @@ 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 ILifetimeScope _services; + + private bool _hasReceivedReady; + private Timer _periodicTask; // Never read, just kept here for GC reasons + + public Bot(ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics, + BotConfig config, + ErrorMessageService errorMessageService, CommandMessageService commandMessageService, + Cluster cluster, DiscordApiClient rest, IDiscordCache cache) { + _logger = logger.ForContext(); + _services = services; + _collector = collector; + _metrics = metrics; + _config = config; + _errorMessageService = errorMessageService; + _commandMessageService = commandMessageService; + _cluster = cluster; + _rest = rest; + _cache = cache; + } - private readonly Cluster _cluster; - private readonly DiscordApiClient _rest; - private readonly ILogger _logger; - private readonly ILifetimeScope _services; - private readonly PeriodicStatCollector _collector; - private readonly IMetrics _metrics; - private readonly BotConfig _config; - private readonly ErrorMessageService _errorMessageService; - private readonly CommandMessageService _commandMessageService; - private readonly IDiscordCache _cache; + public void Init() + { + _cluster.EventReceived += OnEventReceived; - private bool _hasReceivedReady = false; - private Timer _periodicTask; // Never read, just kept here for GC reasons + // Init the shard stuff + _services.Resolve().Init(); - public Bot(ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics, BotConfig config, - ErrorMessageService errorMessageService, CommandMessageService commandMessageService, Cluster cluster, DiscordApiClient rest, IDiscordCache cache) + // 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(_ => { - _logger = logger.ForContext(); - _services = services; - _collector = collector; - _metrics = metrics; - _config = config; - _errorMessageService = errorMessageService; - _commandMessageService = commandMessageService; - _cluster = cluster; - _rest = rest; - _cache = cache; - } + var __ = UpdatePeriodic(); + }, null, timeTillNextWholeMinute, TimeSpan.FromMinutes(1)); + } - public void Init() - { - _cluster.EventReceived += OnEventReceived; + private async Task OnEventReceived(Shard shard, IGatewayEvent evt) + { + await _cache.TryUpdateSelfMember(shard, evt); + await _cache.HandleGatewayEvent(evt); - // Init the shard stuff - _services.Resolve().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 + if (evt is MessageCreateEvent mc) + await HandleEvent(shard, mc); + if (evt is MessageUpdateEvent mu) + await HandleEvent(shard, mu); + if (evt is MessageDeleteEvent md) + await HandleEvent(shard, md); + if (evt is MessageDeleteBulkEvent mdb) + await HandleEvent(shard, mdb); + if (evt is MessageReactionAddEvent mra) + await HandleEvent(shard, mra); + if (evt is InteractionCreateEvent ic) + await HandleEvent(shard, ic); - // 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)); - } + // Update shard status for shards immediately on connect + if (evt is ReadyEvent re) + await HandleReady(shard, re); + if (evt is ResumedEvent) + await HandleResumed(shard); + } - private async Task OnEventReceived(Shard shard, IGatewayEvent evt) - { - await _cache.TryUpdateSelfMember(shard, evt); - await _cache.HandleGatewayEvent(evt); + private Task HandleResumed(Shard shard) => UpdateBotStatus(shard); - // 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(shard, mc); - if (evt is MessageUpdateEvent mu) - await HandleEvent(shard, mu); - if (evt is MessageDeleteEvent md) - await HandleEvent(shard, md); - if (evt is MessageDeleteBulkEvent mdb) - await HandleEvent(shard, mdb); - if (evt is MessageReactionAddEvent mra) - await HandleEvent(shard, mra); - if (evt is InteractionCreateEvent ic) - await HandleEvent(shard, ic); + private Task HandleReady(Shard shard, ReadyEvent _) + { + _hasReceivedReady = true; + return UpdateBotStatus(shard); + } - // Update shard status for shards immediately on connect - if (evt is ReadyEvent re) - await HandleReady(shard, re); - if (evt is ResumedEvent) - await HandleResumed(shard); - } + public async Task Shutdown() + { + // This will stop the timer and prevent any subsequent invocations + await _periodicTask.DisposeAsync(); - private Task HandleResumed(Shard shard) - { - return UpdateBotStatus(shard); - } - - private Task HandleReady(Shard shard, ReadyEvent _) - { - _hasReceivedReady = true; - return UpdateBotStatus(shard); - } - - 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 - if (_hasReceivedReady) - { - await Task.WhenAll(_cluster.Shards.Values.Select(shard => - shard.UpdateStatus(new GatewayStatusUpdate + // 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 Task.WhenAll(_cluster.Shards.Values.Select(shard => + shard.UpdateStatus(new GatewayStatusUpdate + { + Activities = new[] { - Activities = new[] - { - new ActivityPartial - { - Name = "Restarting... (please wait)", - Type = ActivityType.Game - } - }, - Status = GatewayStatusUpdate.UserStatus.Idle - }))); - } - } + new ActivityPartial {Name = "Restarting... (please wait)", Type = ActivityType.Game} + }, + Status = GatewayStatusUpdate.UserStatus.Idle + }))); + } - private Task HandleEvent(Shard shard, T evt) where T : IGatewayEvent + private Task HandleEvent(Shard shard, 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() { - // We don't want to stall the event pipeline, so we'll "fork" inside here - var _ = HandleEventInner(); - return Task.CompletedTask; + await Task.Yield(); - async Task HandleEventInner() + await using var serviceScope = _services.BeginLifetimeScope(); + + // Find an event handler that can handle the type of event () we're given + IEventHandler handler; + try { - 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; - } - - try - { - var queue = serviceScope.ResolveOptional>(); - - using var _ = LogContext.PushProperty("EventId", Guid.NewGuid()); - using var __ = LogContext.Push(await serviceScope.Resolve().GetEnricher(shard, evt)); - _logger.Verbose("Received gateway event: {@Event}", evt); - - // 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); - - 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(shard, evt); - } - catch (Exception exc) - { - await HandleError(shard, handler, evt, serviceScope, exc); - } + handler = serviceScope.Resolve>(); } - } - - private async Task HandleError(Shard shard, IEventHandler handler, T evt, ILifetimeScope serviceScope, Exception exc) - where T : IGatewayEvent - { - _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); - - // If the event is us responding to our own error messages, don't bother logging - if (evt is MessageCreateEvent mc && mc.Author.Id == shard.User?.Id) + catch (Exception e) + { + _logger.Error(e, "Error instantiating handler class"); 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); - 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"); - - await UpdateBotStatus(); - - // 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(Shard 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 = await _cache.GetAllGuilds().CountAsync(); - - try // DiscordClient may throw an exception if the socket is closed (e.g just after OP 7 received) + try { - Task UpdateStatus(Shard shard) => - shard.UpdateStatus(new GatewayStatusUpdate - { - Activities = new[] - { - new ActivityPartial - { - Name = $"pk;help | in {totalGuilds:N0} servers | shard #{shard.ShardId}", - Type = ActivityType.Game, - Url = "https://pluralkit.me/" - } - } - }); + var queue = serviceScope.ResolveOptional>(); - if (specificShard != null) - await UpdateStatus(specificShard); - else // Run shard updates concurrently - await Task.WhenAll(_cluster.Shards.Values.Select(UpdateStatus)); + using var _ = LogContext.PushProperty("EventId", Guid.NewGuid()); + using var __ = LogContext.Push(await serviceScope.Resolve() + .GetEnricher(shard, evt)); + _logger.Verbose("Received gateway event: {@Event}", evt); + + // 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); + + 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(shard, evt); } - catch (WebSocketException) + catch (Exception exc) { - // TODO: this still thrown? + await HandleError(shard, handler, evt, serviceScope, exc); } } } + + private async Task HandleError(Shard shard, IEventHandler handler, T evt, ILifetimeScope serviceScope, + Exception exc) + where T : IGatewayEvent + { + _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); + + // If the event is us responding to our own error messages, don't bother logging + if (evt is MessageCreateEvent mc && mc.Author.Id == shard.User?.Id) + 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); + 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"); + + await UpdateBotStatus(); + + // 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(Shard 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 = await _cache.GetAllGuilds().CountAsync(); + + try // DiscordClient may throw an exception if the socket is closed (e.g just after OP 7 received) + { + Task UpdateStatus(Shard shard) => + shard.UpdateStatus(new GatewayStatusUpdate + { + Activities = new[] + { + new ActivityPartial + { + Name = $"pk;help | in {totalGuilds:N0} servers | shard #{shard.ShardId}", + Type = ActivityType.Game, + Url = "https://pluralkit.me/" + } + } + }); + + if (specificShard != null) + await UpdateStatus(specificShard); + else // Run shard updates concurrently + await Task.WhenAll(_cluster.Shards.Values.Select(UpdateStatus)); + } + catch (WebSocketException) + { + // TODO: this still thrown? + } + } } \ No newline at end of file diff --git a/PluralKit.Bot/BotConfig.cs b/PluralKit.Bot/BotConfig.cs index dd4a6a0e..746be9e5 100644 --- a/PluralKit.Bot/BotConfig.cs +++ b/PluralKit.Bot/BotConfig.cs @@ -1,34 +1,33 @@ -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class BotConfig { - 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 string? DiscordBaseUrl { get; set; } + + public bool DisableErrorReporting { get; set; } = false; + + public record ClusterSettings { - 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 string? DiscordBaseUrl { get; set; } - - public bool DisableErrorReporting { get; set; } = false; - - public record ClusterSettings - { - public string NodeName { get; set; } - public int TotalShards { get; set; } - public int TotalNodes { get; set; } - } + public string NodeName { get; set; } + public int TotalShards { get; set; } + public int TotalNodes { get; set; } } } \ No newline at end of file diff --git a/PluralKit.Bot/BotMetrics.cs b/PluralKit.Bot/BotMetrics.cs index 160ad762..eca473a0 100644 --- a/PluralKit.Bot/BotMetrics.cs +++ b/PluralKit.Bot/BotMetrics.cs @@ -3,28 +3,154 @@ 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 TimerOptions CommandTime => new TimerOptions { Name = "Command run time", MeasurementUnit = Unit.Commands, RateUnit = TimeUnit.Seconds, DurationUnit = 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 GaugeOptions MembersTotal => new() + { + Name = "Members total", + MeasurementUnit = Unit.None, + Context = "Bot" + }; + + public static GaugeOptions MembersOnline => new() + { + Name = "Members online", + MeasurementUnit = Unit.None, + Context = "Bot" + }; + + public static GaugeOptions Guilds => new() + { + Name = "Guilds", + MeasurementUnit = Unit.None, + Context = "Bot" + }; + public static GaugeOptions Channels => new() + { + Name = "Channels", + MeasurementUnit = Unit.None, + Context = "Bot" + }; + + public static GaugeOptions ShardLatency => new() + { + Name = "Shard Latency", + Context = "Bot" + }; + + public static GaugeOptions ShardsConnected => new() + { + Name = "Shards Connected", + Context = "Bot", + MeasurementUnit = Unit.Connections + }; + + public static MeterOptions WebhookCacheMisses => new() + { + Name = "Webhook cache misses", + Context = "Bot", + MeasurementUnit = Unit.Calls + }; + + public static GaugeOptions WebhookCacheSize => new() + { + Name = "Webhook Cache Size", + Context = "Bot", + MeasurementUnit = Unit.Items + }; + + 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/CommandSystem/Command.cs b/PluralKit.Bot/CommandSystem/Command.cs index a1d1061e..ff6abdcd 100644 --- a/PluralKit.Bot/CommandSystem/Command.cs +++ b/PluralKit.Bot/CommandSystem/Command.cs @@ -1,16 +1,15 @@ -namespace PluralKit.Bot -{ - public class Command - { - public string Key { get; } - public string Usage { get; } - public string Description { get; } +namespace PluralKit.Bot; - public Command(string key, string usage, string description) - { - Key = key; - Usage = usage; - Description = description; - } +public class Command +{ + public Command(string key, string usage, string 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 dcad926f..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 index e651c7f9..0b21719d 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -15,138 +15,126 @@ using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Context { - public class Context + private readonly ILifetimeScope _provider; + + private readonly Parameters _parameters; + + private readonly IMetrics _metrics; + private readonly CommandMessageService _commandMessageService; + + private Command? _currentCommand; + + public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, + PKSystem senderSystem, MessageContext messageContext) { - private readonly ILifetimeScope _provider; - - private readonly DiscordApiClient _rest; - private readonly Cluster _cluster; - private readonly Shard _shard; - private readonly Guild? _guild; - private readonly Channel _channel; - private readonly MessageCreateEvent _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 readonly IDiscordCache _cache; - - private Command? _currentCommand; - - public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, - PKSystem senderSystem, MessageContext messageContext) - { - _message = message; - _shard = shard; - _guild = guild; - _channel = channel; - _senderSystem = senderSystem; - _messageContext = messageContext; - _cache = provider.Resolve(); - _db = provider.Resolve(); - _repo = provider.Resolve(); - _metrics = provider.Resolve(); - _provider = provider; - _commandMessageService = provider.Resolve(); - _parameters = new Parameters(message.Content?.Substring(commandParseOffset)); - _rest = provider.Resolve(); - _cluster = provider.Resolve(); - } - - public IDiscordCache Cache => _cache; - - public Channel Channel => _channel; - public User Author => _message.Author; - public GuildMemberPartial Member => _message.Member; - - public Message Message => _message; - public Guild Guild => _guild; - public Shard Shard => _shard; - public Cluster Cluster => _cluster; - public MessageContext MessageContext => _messageContext; - - public Task BotPermissions => _provider.Resolve().PermissionsIn(_channel.Id); - public Task UserPermissions => _cache.PermissionsFor(_message); - - public DiscordApiClient 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, 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, - Embed = embed, - // 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) - { - _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?"); - } - } - - 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; + Message = (Message)message; + Shard = shard; + Guild = guild; + Channel = channel; + System = senderSystem; + MessageContext = messageContext; + Cache = provider.Resolve(); + Database = provider.Resolve(); + Repository = provider.Resolve(); + _metrics = provider.Resolve(); + _provider = provider; + _commandMessageService = provider.Resolve(); + _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 Shard Shard; + 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 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, + Embed = embed, + // 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) + { + _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?"); + } + } + + 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/ContextArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs index ca05ab4a..5933fbe5 100644 --- a/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs @@ -1,145 +1,139 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public static class ContextArgumentsExt { - 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) { - 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) + var arg = ctx.PeekArgument(); + foreach (var match in potentialMatches) + if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase)) { - if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase)) - { - used = ctx.PopArgument(); - return true; - } + used = ctx.PopArgument(); + return true; } - return false; - } + 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) + /// + /// 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 bool MatchRaw(this Context ctx) => + ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw"); + + 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()) { - string used = null; // Unused and unreturned, we just yeet it - return ctx.Match(ref used, potentialMatches); + // 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 } - public static bool MatchFlag(this Context ctx, params string[] potentialMatches) + 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()) { - // 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 + // 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())); - var flags = ctx.Parameters.Flags(); - return potentialMatches.Any(potentialMatch => flags.Contains(potentialMatch)); + // 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 } - 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; - } + if (groups.Count == 0) throw new PKSyntaxError("You must input at least one group."); - public static bool MatchRaw(this Context ctx) => - ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw"); - - 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; - } + return groups; } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs index b4a137ea..fb9d1ef1 100644 --- a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs @@ -1,6 +1,3 @@ -using System.Linq; -using System.Threading.Tasks; - using Autofac; using Myriad.Extensions; @@ -8,97 +5,100 @@ using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public static class ContextChecksExt { - public static class ContextChecksExt + public static Context CheckGuildContext(this Context ctx) { - 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."); - } + 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 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, 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 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 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 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) + { + 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 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 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) + public static async Task CheckPermissionsInGuildChannel(this Context ctx, Channel channel, + PermissionSet neededPerms) + { + var guild = await ctx.Cache.GetGuild(channel.GuildId.Value); + if (guild == null) + return false; + + var guildMember = ctx.Member; + + if (ctx.Guild?.Id != channel.GuildId) { - var guild = await ctx.Cache.GetGuild(channel.GuildId.Value); - if (guild == null) + guildMember = await ctx.Rest.GetGuildMember(channel.GuildId.Value, ctx.Author.Id); + if (guildMember == 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); - } + var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, guildMember); + if ((userPermissions & neededPerms) == 0) + return false; - public static Context AssertBotAdmin(this Context ctx) - { - if (!ctx.CheckBotAdmin()) - throw new PKError("This command is only usable by bot admins."); + return true; + } - return ctx; - } + 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/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index c5d05553..437ec769 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -1,187 +1,180 @@ -using System; -using System.Threading.Tasks; - using Myriad.Extensions; using Myriad.Types; using PluralKit.Bot.Utils; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public static class ContextEntityArgumentsExt { - public static class ContextEntityArgumentsExt + public static async Task MatchUser(this Context ctx) { - 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); + 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), try member HID parsing: + if (await ctx.Repository.GetMemberByHid(input, restrictToSystem) is PKMember memberByHid) + 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; - } - 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), try member HID parsing: - if (await ctx.Repository.GetMemberByHid(input, restrictToSystem) is PKMember memberByHid) - return memberByHid; - - // We didn't find anything, so we return null. + if (!(await ctx.Cache.TryGetChannel(id) is Channel channel)) 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; + if (!DiscordUtils.IsValidGuildChannel(channel)) return null; - } - public static async Task MatchGroup(this Context ctx, SystemId? restrictToSystem = null) + ctx.PopArgument(); + return channel; + } + + public static async Task MatchGuild(this Context ctx) + { + try { - var group = await ctx.PeekGroup(restrictToSystem); - if (group != null) ctx.PopArgument(); - return group; + var id = ulong.Parse(ctx.PeekArgument()); + var guild = await ctx.Cache.TryGetGuild(id); + if (guild != null) + ctx.PopArgument(); + + return guild; } - - public static string CreateNotFoundError(this Context ctx, string entity, string input) + catch (FormatException) { - var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id"); - - if (isIDOnlyQuery) - { - if (input.Length == 5) - return $"{entity} with ID \"{input}\" not found."; - else - return $"{entity} not found. Note that a {entity.ToLower()} ID is 5 characters long."; - } - else - { - if (input.Length == 5) - return $"{entity} with ID or name \"{input}\" not found."; - else - 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; - - if (!(await ctx.Cache.TryGetChannel(id) is Channel channel)) - return null; - - if (!DiscordUtils.IsValidGuildChannel(channel)) - return null; - - ctx.PopArgument(); - return channel; - } - - public static async Task MatchGuild(this Context ctx) - { - try - { - var id = ulong.Parse(ctx.PeekArgument()); - var guild = await ctx.Cache.TryGetGuild(id); - if (guild != null) - ctx.PopArgument(); - - return guild; - } - catch (FormatException) - { - return null; - } + return null; } } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Parameters.cs b/PluralKit.Bot/CommandSystem/Parameters.cs index a9eb73fa..0010438a 100644 --- a/PluralKit.Bot/CommandSystem/Parameters.cs +++ b/PluralKit.Bot/CommandSystem/Parameters.cs @@ -1,187 +1,178 @@ -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 + private 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() + { + // 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 (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 index 984b0919..fdce062c 100644 --- a/PluralKit.Bot/Commands/Admin.cs +++ b/PluralKit.Bot/Commands/Admin.cs @@ -1,143 +1,145 @@ -using System.Linq; using System.Text.RegularExpressions; -using System.Threading.Tasks; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Admin { - public class Admin + private readonly BotConfig _botConfig; + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public Admin(BotConfig botConfig, IDatabase db, ModelRepository repo) { - private readonly BotConfig _botConfig; - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _botConfig = botConfig; + _db = db; + _repo = repo; + } - public Admin(BotConfig botConfig, IDatabase db, ModelRepository repo) + 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 _repo.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 _repo.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 _repo.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 _repo.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 _repo.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 _repo.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 currentLimit = target.MemberLimitOverride ?? Limits.MaxMemberCount; + if (!ctx.HasNext()) { - _botConfig = botConfig; - _db = db; - _repo = repo; + await ctx.Reply($"Current member limit is **{currentLimit}** members."); + return; } - public async Task UpdateSystemId(Context ctx) + 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 _repo.UpdateSystem(target.Id, new SystemPatch { 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 currentLimit = target.GroupLimitOverride ?? Limits.MaxGroupCount; + if (!ctx.HasNext()) { - 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 _repo.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 _repo.UpdateSystem(target.Id, new() { Hid = newHid }); - await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`)."); + await ctx.Reply($"Current group limit is **{currentLimit}** groups."); + return; } - public async Task UpdateMemberId(Context ctx) - { - ctx.AssertBotAdmin(); + var newLimitStr = ctx.PopArgument(); + if (!int.TryParse(newLimitStr, out var newLimit)) + throw new PKError($"Couldn't parse `{newLimitStr}` as number."); - var target = await ctx.MatchMember(); - if (target == null) - throw new PKError("Unknown member."); + if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update")) + throw new PKError("Group limit change cancelled."); - var newHid = ctx.PopArgument(); - if (!Regex.IsMatch(newHid, "^[a-z]{5}$")) - throw new PKError($"Invalid new member ID `{newHid}`."); - - var existingMember = await _repo.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 _repo.UpdateMember(target.Id, new() { 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 _repo.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 _repo.UpdateGroup(target.Id, new() { 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 currentLimit = target.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 _repo.UpdateSystem(target.Id, new() { 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 currentLimit = target.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 _repo.UpdateSystem(target.Id, new() { GroupLimitOverride = newLimit }); - await ctx.Reply($"{Emojis.Success} Group limit updated."); - } + await _repo.UpdateSystem(target.Id, new SystemPatch { 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 index 0b594d1c..7147ecdb 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -1,6 +1,4 @@ -using System; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Myriad.Extensions; using Myriad.Rest.Exceptions; @@ -9,148 +7,145 @@ using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Api { - public class Api + private static readonly Regex _webhookRegex = + new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)"); + + private readonly DispatchService _dispatch; + private readonly ModelRepository _repo; + + public Api(ModelRepository repo, DispatchService dispatch) { - private readonly ModelRepository _repo; - private readonly DispatchService _dispatch; - private static readonly Regex _webhookRegex = new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)"); - public Api(ModelRepository repo, DispatchService dispatch) + _repo = repo; + _dispatch = dispatch; + } + + public async Task GetToken(Context ctx) + { + ctx.CheckSystem(); + + // Get or make a token + var token = ctx.System.Token ?? await MakeAndSetNewToken(ctx.System); + + try { - _repo = repo; - _dispatch = dispatch; - } - - 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.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id); - await ctx.Rest.CreateMessage(dm.Id, new MessageRequest + // DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile) + var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id); + await ctx.Rest.CreateMessage(dm.Id, + 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:" + 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.Id, new MessageRequest { Content = token }); + await ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = token }); - // 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?"); - } + // 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(PKSystem system) + { + system = await _repo.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; } - private async Task MakeAndSetNewToken(PKSystem system) + try { - system = await _repo.UpdateSystem(system.Id, new() { 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 ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id); - await ctx.Rest.CreateMessage(dm.Id, new MessageRequest + // DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile) + var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id); + await ctx.Rest.CreateMessage(dm.Id, + 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.System); - await ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = token }); + // 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 ctx.Rest.CreateMessage(dm.Id, new MessageRequest { Content = token }); - // 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?"); - } + // 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!"); } - - public async Task SystemWebhook(Context ctx) + catch (ForbiddenException) { - 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 _repo.UpdateSystem(ctx.System.Id, new() - { - 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 _repo.UpdateSystem(ctx.System.Id, new() - { - 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); + // 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 _repo.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 _repo.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 990060c9..43069cba 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading.Tasks; - using Humanizer; using Myriad.Builders; @@ -10,214 +7,243 @@ using NodaTime; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Autoproxy { - public class Autoproxy + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public Autoproxy(IDatabase db, ModelRepository repo) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _db = db; + _repo = repo; + } - public Autoproxy(IDatabase db, ModelRepository repo) + public async Task SetAutoproxyMode(Context ctx) + { + // 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()}."); + } + + private async Task AutoproxyOff(Context ctx) + { + if (ctx.MessageContext.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()}."); - } - - private async Task AutoproxyOff(Context ctx) - { - 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."); - } - } - - private async Task AutoproxyLatch(Context ctx) - { - 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."); - } - } - - private async Task AutoproxyFront(Context ctx) - { - 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."); - } - } - - private async Task AutoproxyMember(Context ctx, PKMember member) - { - ctx.CheckOwnMember(member); - - 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) - { - 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 = ctx.MessageContext.AutoproxyMode switch - { - AutoproxyMode.Front => fronters.Length > 0 ? await _repo.GetMember(fronters[0]) : null, - AutoproxyMode.Member => await _repo.GetMember(ctx.MessageContext.AutoproxyMember.Value), - _ => null - }; - - switch (ctx.MessageContext.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.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) - 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.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.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.Description("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(); - } - - if (!ctx.MessageContext.AllowAutoproxy) - eb.Field(new("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`.")); - - return eb.Build(); - } - - 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(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 (ctx.Match("reset", "default")) 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 _repo.UpdateSystem(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 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}>."); - } - } - - 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}>."); - return; - } - var patch = new AccountPatch { AllowAutoproxy = allow }; - await _repo.UpdateAccount(ctx.Author.Id, patch); - await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); - } - - private async Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) - { - await _repo.GetSystemGuild(ctx.Guild.Id, ctx.System.Id); - - var patch = new SystemGuildPatch { AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember }; - await _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, patch); + await UpdateAutoproxy(ctx, AutoproxyMode.Off, null); + await ctx.Reply($"{Emojis.Success} Autoproxy turned off in this server."); } } + + private async Task AutoproxyLatch(Context ctx) + { + 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."); + } + } + + private async Task AutoproxyFront(Context ctx) + { + 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."); + } + } + + private async Task AutoproxyMember(Context ctx, PKMember member) + { + ctx.CheckOwnMember(member); + + 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) + { + 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 = ctx.MessageContext.AutoproxyMode switch + { + AutoproxyMode.Front => fronters.Length > 0 ? await _repo.GetMember(fronters[0]) : null, + AutoproxyMode.Member => await _repo.GetMember(ctx.MessageContext.AutoproxyMember.Value), + _ => null + }; + + switch (ctx.MessageContext.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.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) + 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.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.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.Description("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(); + } + + 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`.")); + + return eb.Build(); + } + + 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(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; + var overflow = Duration.Zero; + if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) + { + newTimeout = Duration.Zero; + } + else if (ctx.Match("reset", "default")) + { + 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 _repo.UpdateSystem(ctx.System.Id, new SystemPatch { 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 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}>."); + } + } + + 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}>."); + return; + } + + var patch = new AccountPatch { AllowAutoproxy = allow }; + await _repo.UpdateAccount(ctx.Author.Id, patch); + await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); + } + + private async Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) + { + await _repo.GetSystemGuild(ctx.Guild.Id, ctx.System.Id); + + var patch = new SystemGuildPatch { AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember }; + await _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, patch); + } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs b/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs index b924cdf9..aad38c23 100644 --- a/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs +++ b/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs @@ -1,66 +1,61 @@ #nullable enable -using System; -using System.Linq; -using System.Threading.Tasks; - using Myriad.Extensions; using Myriad.Types; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public static class ContextAvatarExt { - public static class ContextAvatarExt + public static async Task MatchImage(this Context ctx) { - public static async Task MatchImage(this Context ctx) + // If we have a user @mention/ID, use their avatar + if (await ctx.MatchUser() is { } user) { - // 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; + var url = user.AvatarUrl("png", 256); + return new ParsedImage { Url = url, Source = AvatarSource.User, SourceUser = user }; } - } - public struct ParsedImage - { - public string Url; - public AvatarSource Source; - public User? SourceUser; - } + // 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); - public enum AvatarSource - { - Url, - User, - Attachment + 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/Commands/Checks.cs b/PluralKit.Bot/Commands/Checks.cs index 73f1db88..8c7180bb 100644 --- a/PluralKit.Bot/Commands/Checks.cs +++ b/PluralKit.Bot/Commands/Checks.cs @@ -1,7 +1,3 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - using Humanizer; using Myriad.Builders; @@ -13,262 +9,261 @@ using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Checks { - public class Checks + private readonly Bot _bot; + private readonly BotConfig _botConfig; + private readonly IDiscordCache _cache; + private readonly IDatabase _db; + private readonly ProxyMatcher _matcher; + private readonly ProxyService _proxy; + private readonly ModelRepository _repo; + private readonly DiscordApiClient _rest; + + private readonly PermissionSet[] requiredPermissions = { - private readonly DiscordApiClient _rest; - private readonly Bot _bot; - private readonly IDiscordCache _cache; - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly BotConfig _botConfig; - private readonly ProxyService _proxy; - private readonly ProxyMatcher _matcher; + PermissionSet.ViewChannel, PermissionSet.SendMessages, PermissionSet.AddReactions, + PermissionSet.AttachFiles, PermissionSet.EmbedLinks, PermissionSet.ManageMessages, + PermissionSet.ManageWebhooks + }; - public Checks(DiscordApiClient rest, Bot bot, IDiscordCache cache, IDatabase db, ModelRepository repo, - BotConfig botConfig, ProxyService proxy, ProxyMatcher matcher) + public Checks(DiscordApiClient rest, Bot bot, IDiscordCache cache, IDatabase db, ModelRepository repo, + BotConfig botConfig, ProxyService proxy, ProxyMatcher matcher) + { + _rest = rest; + _bot = bot; + _cache = cache; + _db = db; + _repo = repo; + _botConfig = botConfig; + _proxy = proxy; + _matcher = matcher; + } + + public async Task PermCheckGuild(Context ctx) + { + Guild guild; + GuildMemberPartial senderGuildUser = null; + + if (ctx.Guild != null && !ctx.HasNext()) { - _rest = rest; - _bot = bot; - _cache = cache; - _db = db; - _repo = repo; - _botConfig = botConfig; - _proxy = proxy; - _matcher = matcher; + 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); } - private readonly PermissionSet[] requiredPermissions = new[] - { - PermissionSet.ViewChannel, - PermissionSet.SendMessages, - PermissionSet.AddReactions, - PermissionSet.AttachFiles, - PermissionSet.EmbedLinks, - PermissionSet.ManageMessages, - PermissionSet.ManageWebhooks - }; - - public async Task PermCheckGuild(Context ctx) + // 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)) { - 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 (Myriad.Rest.Exceptions.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); - } - - // 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 = await _cache.PermissionsIn(channel.Id); - var webhookPermissions = await _cache.EveryonePermissions(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($"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(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."; - var channel = await ctx.MatchChannel(); - if (channel == null || channel.GuildId == null) - throw new PKError(error); - - if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel)) - throw new PKError(error); - var botPermissions = await _cache.PermissionsIn(channel.Id); var webhookPermissions = await _cache.EveryonePermissions(channel); + var userPermissions = + PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, senderGuildUser); - // We use a bitfield so we can set individual permission bits - ulong missingPermissions = 0; + 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) - missingPermissions |= (ulong)requiredPermission; + missingPermissionField |= (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}"); + missingPermissionField |= (ulong)PermissionSet.UseExternalEmojis; + missingEmojiPermissions = true; } - await ctx.Reply(embed: eb.Build()); + // 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); + } } - public async Task MessageProxyCheck(Context ctx) + // 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 (!ctx.HasNext() && ctx.Message.MessageReference == null) - throw new PKSyntaxError("You need to specify a message."); + if (hiddenChannels) footer += " | "; + footer += + "Use External Emojis permissions must be granted to the @everyone role / Default Permissions."; + } - 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."; + if (footer.Length > 0) + eb.Footer(new Embed.EmbedFooter(footer)); - var (messageId, channelId) = ctx.MatchMessage(false); - if (messageId == null || channelId == null) - throw new PKError(failedToGetMessage); + // Send! :) + await ctx.Reply(embed: eb.Build()); + } - var proxiedMsg = await _db.Execute(conn => _repo.GetMessage(conn, messageId.Value)); - if (proxiedMsg != null) - { - await ctx.Reply($"{Emojis.Success} This message was proxied successfully."); - return; - } + public async Task PermCheckChannel(Context ctx) + { + if (!ctx.HasNext()) + throw new PKSyntaxError("You need to specify a channel."); - // get the message info - var msg = await _rest.GetMessageOrNull(channelId.Value, messageId.Value); - if (msg == null) - throw new PKError(failedToGetMessage); + var error = "Channel not found or you do not have permissions to access it."; + var channel = await ctx.MatchChannel(); + if (channel == null || channel.GuildId == null) + throw new PKError(error); - // 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 (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel)) + throw new PKError(error); - 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."); - 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."); + var botPermissions = await _cache.PermissionsIn(channel.Id); + var webhookPermissions = await _cache.EveryonePermissions(channel); - // get the channel info - var channel = await _cache.GetChannel(channelId.Value); - if (channel == null) - throw new PKError("Unable to get the channel associated with this message."); + // We use a bitfield so we can set individual permission bits + ulong missingPermissions = 0; - // using channel.GuildId here since _rest.GetMessage() doesn't return the GuildId - var context = await _repo.GetMessageContext(msg.Author.Id, channel.GuildId.Value, msg.ChannelId); - var members = (await _repo.GetProxyMembers(msg.Author.Id, channel.GuildId.Value)).ToList(); + foreach (var requiredPermission in requiredPermissions) + if ((botPermissions & requiredPermission) == 0) + missingPermissions |= (ulong)requiredPermission; - // Run everything through the checks, catch the ProxyCheckFailedException, and reply with the error message. - try - { - _proxy.ShouldProxy(channel, msg, context); - _matcher.TryMatch(context, members, out var match, msg.Content, msg.Attachments.Length > 0, context.AllowAutoproxy); + if ((webhookPermissions & PermissionSet.UseExternalEmojis) == 0) + missingPermissions |= (ulong)PermissionSet.UseExternalEmojis; - await ctx.Reply("I'm not sure why this message was not proxied, sorry."); - } - catch (ProxyService.ProxyChecksFailedException e) - { - await ctx.Reply($"{e.Message}"); - } + // 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 _db.Execute(conn => _repo.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."); + 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 _cache.GetChannel(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 _repo.GetMessageContext(msg.Author.Id, channel.GuildId.Value, msg.ChannelId); + var members = (await _repo.GetProxyMembers(msg.Author.Id, channel.GuildId.Value)).ToList(); + + // Run everything through the checks, catch the ProxyCheckFailedException, and reply with the error message. + try + { + _proxy.ShouldProxy(channel, msg, context); + _matcher.TryMatch(context, 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 index fc47b3c8..ca4eea7f 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -1,619 +1,636 @@ -using System.Linq; -using System.Threading.Tasks; - using Humanizer; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class CommandTree { - public class CommandTree + // todo: move these to a different file + 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 SystemColor = new Command("system color", "system color [color]", "Changes your system's color"); + public static Command SystemTag = new Command("system tag", "system tag [tag]", "Changes your system's tag"); + public static Command SystemServerTag = new Command("system servertag", "system servertag [tag|enable|disable]", "Changes your system's tag in the current server"); + public static Command SystemAvatar = new Command("system icon", "system icon [url|@mention]", "Changes your system's icon"); + public static Command SystemBannerImage = new Command("system banner", "system banner [url]", "Set the system's banner image"); + 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 [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 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 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 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 = { - 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 SystemColor = new Command("system color", "system color [color]", "Changes your system's color"); - public static Command SystemTag = new Command("system tag", "system tag [tag]", "Changes your system's tag"); - public static Command SystemServerTag = new Command("system servertag", "system servertag [tag|enable|disable]", "Changes your system's tag in the current server"); - public static Command SystemAvatar = new Command("system icon", "system icon [url|@mention]", "Changes your system's icon"); - public static Command SystemBannerImage = new Command("system banner", "system banner [url]", "Set the system's banner image"); - 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 [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 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 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 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)"); + SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemBannerImage, SystemColor, + SystemDelete, SystemTimezone, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent, + SystemPrivacy, SystemProxy + }; - public static Command[] SystemCommands = { - SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemBannerImage, SystemColor, 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, MemberBannerImage, MemberPrivacy, + MemberRandom + }; - 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[] 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[] 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[] SwitchCommands = { Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, SwitchDelete, SwitchDeleteAll }; + public static Command[] AutoproxyCommands = { AutoproxySet, AutoproxyTimeout, AutoproxyAccount }; - public static Command[] AutoproxyCommands = { AutoproxySet, AutoproxyTimeout, AutoproxyAccount }; + public static Command[] LogCommands = { LogChannel, LogChannelClear, LogEnable, LogDisable }; - public static Command[] LogCommands = { LogChannel, LogChannelClear, LogEnable, LogDisable }; + public static Command[] BlacklistCommands = { BlacklistAdd, BlacklistRemove, BlacklistShow }; - public static Command[] BlacklistCommands = { BlacklistAdd, BlacklistRemove, BlacklistShow }; - - 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("edit", "e")) - return ctx.Execute(MessageEdit, m => m.EditMessage(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("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)); + 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 - 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) - { - // 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("servertag")) - await ctx.Execute(SystemServerTag, m => m.ServerTag(ctx)); - else if (ctx.Match("description", "desc", "bio")) - await ctx.Execute(SystemDesc, m => m.Description(ctx)); - else if (ctx.Match("color", "colour")) - await ctx.Execute(SystemColor, m => m.Color(ctx)); - else if (ctx.Match("banner", "splash", "cover")) - await ctx.Execute(SystemBannerImage, m => m.BannerImage(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("webhook", "hook")) - await ctx.Execute(null, m => m.SystemWebhook(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 - 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("avatar", "picture", "icon", "image", "pic", "pfp")) - await ctx.Execute(SystemAvatar, m => m.Avatar(ctx, target)); - 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.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")) - 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("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.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.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.GroupFrontPercent(ctx, 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`, `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 "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) - { + 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 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)); + 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("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(AutoproxySet, m => m.SetAutoproxyMode(ctx)); - } + 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("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)); - private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands) - { - var commandListStr = CreatePotentialCommandList(potentialCommands); + // 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 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 ."); - } + $"{Emojis.Error} Unknown debug command {ctx.PeekArgument().AsCode()}. {availableCommandsStr}"); + } - private async Task PrintCommandExpectedError(Context ctx, params Command[] potentialCommands) + 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("servertag")) + await ctx.Execute(SystemServerTag, m => m.ServerTag(ctx)); + else if (ctx.Match("description", "desc", "bio")) + await ctx.Execute(SystemDesc, m => m.Description(ctx)); + else if (ctx.Match("color", "colour")) + await ctx.Execute(SystemColor, m => m.Color(ctx)); + else if (ctx.Match("banner", "splash", "cover")) + await ctx.Execute(SystemBannerImage, m => m.BannerImage(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("webhook", "hook")) + await ctx.Execute(null, m => m.SystemWebhook(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")) { - var commandListStr = CreatePotentialCommandList(potentialCommands); + 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 + 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} 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 ."); + $"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\nPerhaps you meant to use one of the following commands?\n{list}"); + } + else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) + await ctx.Execute(SystemAvatar, m => m.Avatar(ctx, target)); + 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.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")) + 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("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.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.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.GroupFrontPercent(ctx, 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`, `autoproxy`, `log`, `blacklist`." + + "\n- **pk;commands ** - *View commands related to a help target.*" + + "\n\nFor the full list of commands, see the website: "); + return; } - private static string CreatePotentialCommandList(params Command[] potentialCommands) + switch (ctx.PeekArgument()) { - 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."; - else - return $"Account with ID `{id}` not found."; - } - - return $"System with ID {input.AsCode()} not found."; + 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) + { + 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)); + if (ctx.Match("timeout", "tm")) + return ctx.Execute(AutoproxyTimeout, m => m.AutoproxyTimeout(ctx)); + 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.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/Commands/Fun.cs b/PluralKit.Bot/Commands/Fun.cs index a6c1eb88..4f588fe9 100644 --- a/PluralKit.Bot/Commands/Fun.cs +++ b/PluralKit.Bot/Commands/Fun.cs @@ -1,34 +1,47 @@ -using System.Threading.Tasks; - using Myriad.Builders; +using Myriad.Types; using NodaTime; using PluralKit.Core; -namespace PluralKit.Bot -{ - 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 Error(Context ctx) - { - if (ctx.Match("message")) - return ctx.Reply($"> **Error code:** `50f3c7b439d111ecab2023a5431fffbd`", embed: 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("50f3c7b439d111ecab2023a5431fffbd")) - .Timestamp(SystemClock.Instance.GetCurrentInstant().ToDateTimeOffset().ToString("O")) - .Build() - ); +namespace PluralKit.Bot; - return ctx.Reply($"{Emojis.Error} Unknown command {"error".AsCode()}. For a list of possible commands, see ."); - } +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 Error(Context ctx) + { + 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/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 1f83f6d7..264ac09e 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -1,620 +1,663 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; - -using Dapper; using Humanizer; +using Myriad.Builders; +using Myriad.Types; + using Newtonsoft.Json.Linq; using NodaTime; -using Myriad.Builders; - using PluralKit.Core; -namespace PluralKit.Bot -{ - public class Groups - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; - private readonly HttpClient _client; - private readonly DispatchService _dispatch; +namespace PluralKit.Bot; - public Groups(IDatabase db, ModelRepository repo, EmbedService embeds, HttpClient client, DispatchService dispatch) +public class Groups +{ + public enum AddRemoveOperation + { + Add, + Remove + } + + private readonly HttpClient _client; + private readonly IDatabase _db; + private readonly DispatchService _dispatch; + private readonly EmbedService _embeds; + private readonly ModelRepository _repo; + + public Groups(IDatabase db, ModelRepository repo, EmbedService embeds, HttpClient client, + DispatchService dispatch) + { + _db = db; + _repo = repo; + _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 _repo.GetSystemGroupCount(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."); + + // Warn if there's already a group by this name + var existingGroup = await _repo.GetGroupByName(ctx.System.Id, groupName); + if (existingGroup != null) { - _db = db; - _repo = repo; - _embeds = embeds; - _client = client; - _dispatch = dispatch; + 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(); + var newGroup = await _repo.CreateGroup(ctx.System.Id, groupName); - // 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 _repo.GetSystemGroupCount(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."); - - // Warn if there's already a group by this name - var existingGroup = await _repo.GetGroupByName(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, "Create")) - throw new PKError("Group creation cancelled."); - } - - var newGroup = await _repo.CreateGroup(ctx.System.Id, groupName); - - _ = _dispatch.Dispatch(newGroup.Id, new UpdateDispatchData() + _ = _dispatch.Dispatch(newGroup.Id, + new UpdateDispatchData { Event = DispatchEvent.CREATE_GROUP, - EventData = JObject.FromObject(new { name = groupName }), + EventData = JObject.FromObject(new { name = groupName }) }); - 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("View the group card", $"> pk;group **{newGroup.Reference()}**")) - .Field(new("Add members to the group", $"> pk;group **{newGroup.Reference()}** add **MemberName**\n> pk;group **{newGroup.Reference()}** add **Member1** **Member2** **Member3** (and so on...)")) - .Field(new("Set the description", $"> pk;group **{newGroup.Reference()}** description **This is my new group, and here is the description!**")) - .Field(new("Set the group icon", $"> pk;group **{newGroup.Reference()}** icon\n*(with an image attached)*")); - await ctx.Reply($"{Emojis.Success} Group created!", eb.Build()); + 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 **{newGroup.Reference()}**")) + .Field(new Embed.Field("Add members to the group", + $"> pk;group **{newGroup.Reference()}** add **MemberName**\n> pk;group **{newGroup.Reference()}** add **Member1** **Member2** **Member3** (and so on...)")) + .Field(new Embed.Field("Set the description", + $"> pk;group **{newGroup.Reference()}** description **This is my new group, and here is the description!**")) + .Field(new Embed.Field("Set the group icon", + $"> pk;group **{newGroup.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."); + 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 _repo.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."); } - public async Task RenameGroup(Context ctx, PKGroup target) + await _repo.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()} displayname `."; + + // No perms check, display name isn't covered by member privacy + + if (ctx.MatchRaw()) { - 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 _repo.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 _repo.UpdateGroup(target.Id, new() { Name = newName }); - - await ctx.Reply($"{Emojis.Success} Group name changed from **{target.Name}** to **{newName}**."); + if (target.DisplayName == null) + await ctx.Reply(noDisplayNameSetMessage); + else + await ctx.Reply($"```\n{target.DisplayName}\n```"); + return; } - public async Task GroupDisplayName(Context ctx, PKGroup target) + if (!ctx.HasNext(false)) { - 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()} displayname `."; - - // No perms check, display name isn't covered by member privacy - - if (ctx.MatchRaw()) + if (target.DisplayName == null) { - 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); - else - { - var eb = new EmbedBuilder() - .Field(new("Name", target.Name)) - .Field(new("Display Name", target.DisplayName)); - - if (ctx.System?.Id == target.System) - eb.Description($"To change display name, type `pk;group {target.Reference()} displayname `." - + $"To clear it, type `pk;group {target.Reference()} displayname -clear`." - + $"To print the raw display name, type `pk;group {target.Reference()} displayname -raw`."); - - await ctx.Reply(embed: eb.Build()); - } - return; - } - - ctx.CheckOwnGroup(target); - - if (await ctx.MatchClear("this group's display name")) - { - var patch = new GroupPatch { DisplayName = Partial.Null() }; - await _repo.UpdateGroup(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Group display name cleared."); + await ctx.Reply(noDisplayNameSetMessage); } else { - var newDisplayName = ctx.RemainderOrNull(skipFlags: false).NormalizeLineEndSpacing(); + var eb = new EmbedBuilder() + .Field(new Embed.Field("Name", target.Name)) + .Field(new Embed.Field("Display Name", target.DisplayName)); - var patch = new GroupPatch { DisplayName = Partial.Present(newDisplayName) }; - await _repo.UpdateGroup(target.Id, patch); + if (ctx.System?.Id == target.System) + eb.Description( + $"To change display name, type `pk;group {target.Reference()} displayname `." + + $"To clear it, type `pk;group {target.Reference()} displayname -clear`." + + $"To print the raw display name, type `pk;group {target.Reference()} displayname -raw`."); - await ctx.Reply($"{Emojis.Success} Group display name changed."); + await ctx.Reply(embed: eb.Build()); + } + + return; + } + + ctx.CheckOwnGroup(target); + + if (await ctx.MatchClear("this group's display name")) + { + var patch = new GroupPatch { DisplayName = Partial.Null() }; + await _repo.UpdateGroup(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Group display name cleared."); + } + else + { + var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + + var patch = new GroupPatch { DisplayName = Partial.Present(newDisplayName) }; + await _repo.UpdateGroup(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Group display name changed."); + } + } + + public async Task GroupDescription(Context ctx, PKGroup target) + { + if (!target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) + throw Errors.LookupNotAllowed; + + 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()} 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()} description -raw`." + + (ctx.System?.Id == target.System + ? $" To clear it, type `pk;group {target.Reference()} description -clear`." + : ""))) + .Build()); + return; + } + + ctx.CheckOwnGroup(target); + + if (await ctx.MatchClear("this group's description")) + { + var patch = new GroupPatch { Description = Partial.Null() }; + await _repo.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 _repo.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); + + await _repo.UpdateGroup(target.Id, new GroupPatch { Icon = null }); + await ctx.Reply($"{Emojis.Success} Group icon cleared."); + } + + async Task SetIcon(ParsedImage img) + { + ctx.CheckOwnGroup(target); + + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); + + await _repo.UpdateGroup(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, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + async Task ShowIcon() + { + if (!target.IconPrivacy.CanAccess(ctx.LookupContextFor(target.System))) + throw Errors.LookupNotAllowed; + 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()} 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."); } } - public async Task GroupDescription(Context ctx, PKGroup target) + 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 GroupBannerImage(Context ctx, PKGroup target) + { + async Task ClearBannerImage() + { + ctx.CheckOwnGroup(target); + + await _repo.UpdateGroup(target.Id, new GroupPatch { BannerImage = null }); + await ctx.Reply($"{Emojis.Success} Group banner image cleared."); + } + + async Task SetBannerImage(ParsedImage img) + { + ctx.CheckOwnGroup(target); + + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true); + + await _repo.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)); + } + + async Task ShowBannerImage() { if (!target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) throw Errors.LookupNotAllowed; - - 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()} description `."; - - if (ctx.MatchRaw()) + if ((target.BannerImage?.Trim() ?? "").Length > 0) { - 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("\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()); - return; - } + 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()} 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); - if (await ctx.MatchClear("this group's description")) - { - var patch = new GroupPatch { Description = Partial.Null() }; - await _repo.UpdateGroup(target.Id, patch); - await ctx.Reply($"{Emojis.Success} Group description cleared."); - } - else - { - var description = ctx.RemainderOrNull(skipFlags: false).NormalizeLineEndSpacing(); - if (description.IsLongerThan(Limits.MaxDescriptionLength)) - throw Errors.StringTooLongError("Description", description.Length, Limits.MaxDescriptionLength); + var patch = new GroupPatch { Color = Partial.Null() }; + await _repo.UpdateGroup(target.Id, patch); - var patch = new GroupPatch { Description = Partial.Present(description) }; - await _repo.UpdateGroup(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Group description changed."); - } + await ctx.Reply($"{Emojis.Success} Group color cleared."); } - - public async Task GroupIcon(Context ctx, PKGroup target) + else if (!ctx.HasNext()) { - async Task ClearIcon() - { - ctx.CheckOwnGroup(target); - - await _repo.UpdateGroup(target.Id, new() { Icon = null }); - await ctx.Reply($"{Emojis.Success} Group icon cleared."); - } - - async Task SetIcon(ParsedImage img) - { - ctx.CheckOwnGroup(target); - - await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); - - await _repo.UpdateGroup(target.Id, new() { 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 EmbedBuilder().Image(new(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowIcon() - { - if (!target.IconPrivacy.CanAccess(ctx.LookupContextFor(target.System))) - throw Errors.LookupNotAllowed; - if ((target.Icon?.Trim() ?? "").Length > 0) - { - var eb = new EmbedBuilder() - .Title("Group icon") - .Image(new(target.Icon.TryGetCleanCdnUrl())); - - if (target.System == ctx.System?.Id) - { - eb.Description($"To clear, use `pk;group {target.Reference()} icon -clear`."); - } - - await ctx.Reply(embed: eb.Build()); - } + 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()} color `."); 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); + await ctx.Reply("This group does not have a color set."); else - await ShowIcon(); - } - - public async Task GroupBannerImage(Context ctx, PKGroup target) - { - async Task ClearBannerImage() - { - ctx.CheckOwnGroup(target); - - await _repo.UpdateGroup(target.Id, new() { BannerImage = null }); - await ctx.Reply($"{Emojis.Success} Group banner image cleared."); - } - - async Task SetBannerImage(ParsedImage img) - { - ctx.CheckOwnGroup(target); - - await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, isFullSizeImage: true); - - await _repo.UpdateGroup(target.Id, new() { 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, embed: new EmbedBuilder().Image(new(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowBannerImage() - { - if (!target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) - throw Errors.LookupNotAllowed; - if ((target.BannerImage?.Trim() ?? "").Length > 0) - { - var eb = new EmbedBuilder() - .Title("Group banner image") - .Image(new(target.BannerImage)); - - if (target.System == ctx.System?.Id) - { - eb.Description($"To clear, use `pk;group {target.Reference()} 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 _repo.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()} 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($"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()} 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 _repo.UpdateGroup(target.Id, patch); - await ctx.Reply(embed: new EmbedBuilder() - .Title($"{Emojis.Success} Group color changed.") - .Color(color.ToDiscordColor()) - .Thumbnail(new($"https://fakeimg.pl/256x256/{color}/?text=%20")) + .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()} color -clear`." + : "")) .Build()); - } } - - 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 - - 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 _db.Execute(conn => 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, ctx.System.Color, Renderer); - - Task Renderer(EmbedBuilder 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.Footer(new($"{groups.Count} total.")); - return Task.CompletedTask; - } - } - - 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 AddRemoveMembers(Context ctx, PKGroup target, AddRemoveOperation op) + else { ctx.CheckOwnGroup(target); - var members = (await ctx.ParseMemberList(ctx.System.Id)) - .Select(m => m.Id) - .Distinct() - .ToList(); + if (color.StartsWith("#")) color = color.Substring(1); + if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); - var existingMembersInGroup = (await _db.Execute(conn => conn.QueryMemberList(target.System, - new DatabaseViewsExt.MemberListQueryOptions { GroupFilter = target.Id }))) - .Select(m => m.Id.Value) - .Distinct() - .ToHashSet(); + var patch = new GroupPatch { Color = Partial.Present(color.ToLowerInvariant()) }; + await _repo.UpdateGroup(target.Id, patch); - List toAction; - - if (op == AddRemoveOperation.Add) - { - toAction = members - .Where(m => !existingMembersInGroup.Contains(m.Value)) - .ToList(); - await _repo.AddMembersToGroup(target.Id, toAction); - } - else if (op == AddRemoveOperation.Remove) - { - toAction = members - .Where(m => existingMembersInGroup.Contains(m.Value)) - .ToList(); - await _repo.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) - { - var targetSystem = await GetGroupSystem(ctx, target); - 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(), target.Color, 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 EmbedBuilder() - .Title($"Current privacy settings for {target.Name}") - .Field(new("Description", target.DescriptionPrivacy.Explanation())) - .Field(new("Icon", target.IconPrivacy.Explanation())) - .Field(new("Member list", target.ListPrivacy.Explanation())) - .Field(new("Visibility", target.Visibility.Explanation())) - .Description($"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 _repo.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 _repo.UpdateGroup(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) - { - 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 _repo.DeleteGroup(target.Id); - - await ctx.Reply($"{Emojis.Success} Group deleted."); - } - - public async Task GroupFrontPercent(Context ctx, PKGroup target) - { - var targetSystem = await GetGroupSystem(ctx, target); - ctx.CheckSystemPrivacy(targetSystem, targetSystem.FrontHistoryPrivacy); - - var totalSwitches = await _repo.GetSwitchCount(targetSystem.Id); - if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; - - string durationStr = ctx.RemainderOrNull() ?? "30d"; - - var now = SystemClock.Instance.GetCurrentInstant(); - - var rangeStart = DateUtils.ParseDateTime(durationStr, true, targetSystem.Zone); - if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); - if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; - - var title = new StringBuilder($"Frontpercent of {target.DisplayName ?? target.Name} (`{target.Hid}`) in "); - if (targetSystem.Name != null) - title.Append($"{targetSystem.Name} (`{targetSystem.Hid}`)"); - else - title.Append($"`{targetSystem.Hid}`"); - - var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only"); - var showFlat = ctx.MatchFlag("flat"); - var frontpercent = await _db.Execute(c => _repo.GetFrontBreakdown(c, targetSystem.Id, target.Id, rangeStart.Value.ToInstant(), now)); - await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, targetSystem, target, targetSystem.Zone, ctx.LookupContextFor(targetSystem), 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 _repo.GetSystem(target.System)!; + 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, system.GroupListPrivacy); + + // TODO: integrate with the normal "search" system + + 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 _db.Execute(conn => 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, ctx.System.Color, Renderer); + + Task Renderer(EmbedBuilder 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)})"; + return $"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({"member".ToQuantity(g.MemberCount)})"; + })); + eb.Footer(new Embed.EmbedFooter($"{groups.Count} total.")); + return Task.CompletedTask; + } + } + + public async Task ShowGroupCard(Context ctx, PKGroup target) + { + var system = await GetGroupSystem(ctx, target); + await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, system, target)); + } + + // todo: move this to MemberGroup.cs + public async Task AddRemoveMembers(Context ctx, PKGroup target, AddRemoveOperation op) + { + ctx.CheckOwnGroup(target); + + var members = (await ctx.ParseMemberList(ctx.System.Id)) + .Select(m => m.Id) + .Distinct() + .ToList(); + + var existingMembersInGroup = (await _db.Execute(conn => 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(target.Id, toAction); + } + else if (op == AddRemoveOperation.Remove) + { + toAction = members + .Where(m => existingMembersInGroup.Contains(m.Value)) + .ToList(); + await _repo.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) + { + var targetSystem = await GetGroupSystem(ctx, target); + 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(), + target.Color, opts); + } + + 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("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("Visibility", target.Visibility.Explanation())) + .Description( + $"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 _repo.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 _repo.UpdateGroup(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) + { + 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 _repo.DeleteGroup(target.Id); + + await ctx.Reply($"{Emojis.Success} Group deleted."); + } + + public async Task GroupFrontPercent(Context ctx, PKGroup target) + { + var targetSystem = await GetGroupSystem(ctx, target); + ctx.CheckSystemPrivacy(targetSystem, targetSystem.FrontHistoryPrivacy); + + var totalSwitches = await _repo.GetSwitchCount(targetSystem.Id); + if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; + + var durationStr = ctx.RemainderOrNull() ?? "30d"; + + var now = SystemClock.Instance.GetCurrentInstant(); + + var rangeStart = DateUtils.ParseDateTime(durationStr, true, targetSystem.Zone); + if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); + if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; + + var title = new StringBuilder($"Frontpercent of {target.DisplayName ?? target.Name} (`{target.Hid}`) in "); + if (targetSystem.Name != null) + title.Append($"{targetSystem.Name} (`{targetSystem.Hid}`)"); + else + title.Append($"`{targetSystem.Hid}`"); + + var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only"); + var showFlat = ctx.MatchFlag("flat"); + var frontpercent = await _db.Execute(c => + _repo.GetFrontBreakdown(c, targetSystem.Id, target.Id, rangeStart.Value.ToInstant(), now)); + await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, targetSystem, target, + targetSystem.Zone, ctx.LookupContextFor(targetSystem), 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 _repo.GetSystem(target.System)!; + } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index 542025bc..f702e7d1 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -1,32 +1,39 @@ -using System.Threading.Tasks; - 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 EmbedBuilder() - .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.") - .Field(new("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.")) - .Field(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.")) - .Field(new("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.")) - .Field(new("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!")) - .Field(new("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.")) - .Field(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) - .Build()); - } +namespace PluralKit.Bot; - public async Task Explain(Context ctx) - { - 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."); - } +public class Help +{ + public async Task HelpRoot(Context ctx) + { + await ctx.Reply(embed: new EmbedBuilder() + .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.") + .Field(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.")) + .Field(new Embed.Field("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.")) + .Field(new Embed.Field("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.")) + .Field(new Embed.Field("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!")) + .Field(new Embed.Field("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.")) + .Field(new Embed.Field("Support server", + "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78")) + .Footer(new Embed.EmbedFooter( + "By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/")) + .Color(DiscordUtils.Blue) + .Build()); + } + + public async Task Explain(Context ctx) + { + 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."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index f2c3c461..3981899f 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -1,9 +1,4 @@ -using System; -using System.IO; -using System.Linq; -using System.Net.Http; using System.Text; -using System.Threading.Tasks; using Myriad.Extensions; using Myriad.Rest.Exceptions; @@ -12,120 +7,125 @@ using Myriad.Rest.Types.Requests; using Myriad.Types; using Newtonsoft.Json; - 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 JsonSerializerSettings _settings = new() { - private readonly DataFileService _dataFiles; - private readonly HttpClient _client; - private readonly JsonSerializerSettings _settings = new() + // Otherwise it'll mess up/reformat the ISO strings for ???some??? reason >.> + DateParseHandling = DateParseHandling.None + }; + + public ImportExport(DataFileService dataFiles, HttpClient client) + { + _dataFiles = dataFiles; + _client = client; + } + + 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 () => { - // Otherwise it'll mess up/reformat the ISO strings for ???some??? reason >.> - DateParseHandling = DateParseHandling.None - }; - - public ImportExport(DataFileService dataFiles, HttpClient client) - { - _dataFiles = dataFiles; - _client = client; - } - - 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 - { - 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; - } - catch (JsonException) - { - throw Errors.InvalidImportFile; - } - - 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; - } - - if (data.ContainsKey("accounts") - && data.Value("accounts").Type != JTokenType.Null - && data.Value("accounts").Contains((JToken)ctx.Author.Id.ToString())) - { - 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; - } - - 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.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)); - + JObject data; try { - var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id); - - var msg = await ctx.Rest.CreateMessage(dm.Id, - new MessageRequest { Content = $"{Emojis.Success} Here you go!" }, - new[] { new MultipartFile("system.json", stream, null) }); - await ctx.Rest.CreateMessage(dm.Id, 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!"); + 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 (ForbiddenException) + catch (InvalidOperationException) { - // 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?"); + // Invalid URL throws this, we just error back out + throw Errors.InvalidImportFile; } + catch (JsonException) + { + throw Errors.InvalidImportFile; + } + + 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; + } + + if (data.ContainsKey("accounts") + && data.Value("accounts").Type != JTokenType.Null + && data.Value("accounts").Contains(ctx.Author.Id.ToString())) + { + 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; + } + + 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.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 ctx.Cache.GetOrCreateDmChannel(ctx.Rest, ctx.Author.Id); + + var msg = await ctx.Rest.CreateMessage(dm.Id, + new MessageRequest { Content = $"{Emojis.Success} Here you go!" }, + new[] { new MultipartFile("system.json", stream, null) }); + await ctx.Rest.CreateMessage(dm.Id, 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 18526feb..62b9316b 100644 --- a/PluralKit.Bot/Commands/Lists/ContextListExt.cs +++ b/PluralKit.Bot/Commands/Lists/ContextListExt.cs @@ -1,229 +1,252 @@ -using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; using Humanizer; using Myriad.Builders; +using Myriad.Types; using NodaTime; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public static class ContextListExt { - public static class ContextListExt + public static MemberListOptions ParseMemberListOptions(this Context ctx, LookupContext lookupCtx) { - public static MemberListOptions ParseMemberListOptions(this Context ctx, LookupContext lookupCtx) + 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; + + // 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", "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")) + 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", "wa", "wi", "ia", "ii", "img")) + p.IncludeAvatar = true; + if (ctx.MatchFlag("with-pronouns", "wp")) + p.IncludePronouns = 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, IDatabase db, + SystemId system, string embedTitle, string color, MemberListOptions 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 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, color, Renderer); + + // Base renderer, dispatches based on type + Task Renderer(EmbedBuilder eb, IEnumerable page) { - var p = new MemberListOptions(); + // Add a global footer with the filter/sort string + result count + eb.Footer(new Embed.EmbedFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}.")); - // 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; + // Then call the specific renderers + if (opts.Type == ListType.Short) + ShortRenderer(eb, page); + else + LongRenderer(eb, page); - // 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", "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")) - 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", "wa", "wi", "ia", "ii", "img")) - p.IncludeAvatar = true; - if (ctx.MatchFlag("with-pronouns", "wp")) - p.IncludePronouns = 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, string color, 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, color, Renderer); - - // Base renderer, dispatches based on type - Task Renderer(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(m => { - // Add a global footer with the filter/sort string + result count - eb.Footer(new($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}.")); + var ret = $"[`{m.Hid}`] **{m.NameFor(ctx)}** "; - // 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(m => + switch (opts.SortProperty) { - var ret = $"[`{m.Hid}`] **{m.NameFor(ctx)}** "; - - switch (opts.SortProperty) - { - case SortProperty.Birthdate: + case SortProperty.Birthdate: + { + var birthday = m.BirthdayFor(lookupCtx); + if (birthday != null) + ret += $"(birthday: {m.BirthdayString})"; + break; + } + case SortProperty.DisplayName: + { + if (m.DisplayName != null) + 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) { - var birthday = m.BirthdayFor(lookupCtx); - if (birthday != null) - ret += $"(birthday: {m.BirthdayString})"; - break; + ret += $"({count} messages)"; } - case SortProperty.DisplayName: + else if (opts.IncludeLastSwitch && + m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw)) { - if (m.DisplayName != null) - ret += $"({m.DisplayName})"; - break; + ret += $"(last switched in: )"; } - case SortProperty.MessageCount: + // 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)) { - if (m.MessageCountFor(lookupCtx) is { } count) - ret += $"({count} messages)"; - break; + ret += $"(created at )"; } - case SortProperty.LastSwitch: + else if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatarUrl) { - if (m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw)) - ret += $"(last switched in: )"; - break; + ret += $"([avatar URL]({avatarUrl}))"; } - // case SortProperty.LastMessage: - // { - // if (m.MetadataPrivacy.TryGet(lookupCtx, m.LastMessage, out var lastMsg)) - // ret += $"(last message: )"; - // break; - // } - case SortProperty.CreationDate: + else if (opts.IncludePronouns && m.PronounsFor(lookupCtx) is { } pronouns) { - if (m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created)) - ret += $"(created at )"; - break; + ret += $"({pronouns})"; } - default: + else if (m.HasProxyTags) { - if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is { } count) - ret += $"({count} messages)"; - 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}*)*"; - } - break; + var proxyTagsString = m.ProxyTagsString(); + if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak? + proxyTagsString = "tags too long, see member card"; + ret += $"*(*{proxyTagsString}*)*"; } - } - return ret; - })); - } - void LongRenderer(EmbedBuilder 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 || 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(zone)}"); - - if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) && 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.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(m.NameFor(ctx), profile.ToString().Truncate(1024))); + break; + } } + + return ret; + })); + } + + void LongRenderer(EmbedBuilder 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 || 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(zone)}"); + + if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) && + 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.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))); } } } diff --git a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs b/PluralKit.Bot/Commands/Lists/MemberListOptions.cs index d0970885..8e9a67fc 100644 --- a/PluralKit.Bot/Commands/Lists/MemberListOptions.cs +++ b/PluralKit.Bot/Commands/Lists/MemberListOptions.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text; using NodaTime; @@ -8,128 +5,127 @@ using NodaTime; using PluralKit.Core; #nullable enable -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class MemberListOptions { - 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 bool IncludePronouns { get; set; } + + public string CreateFilterString() { - 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 string CreateFilterString() + var str = new StringBuilder(); + str.Append("Sorting "); + if (SortProperty != SortProperty.Random) str.Append("by "); + str.Append(SortProperty switch { - 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}") - }); + 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(); + if (Search != null) + { + str.Append($", searching for \"{Search}\""); + if (SearchDescription) str.Append(" (including description)"); } - 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) + str.Append(PrivacyFilter switch { - IComparer ReverseMaybe(IComparer c) => - opts.Reverse ? Comparer.Create((a, b) => c.Compare(b, a)) : c; + 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}") + }); - 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 => 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) - .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); - } + return str.ToString(); } - public enum SortProperty + public DatabaseViewsExt.MemberListQueryOptions ToQueryOptions() => + new() + { + PrivacyFilter = PrivacyFilter, + GroupFilter = GroupFilter, + Search = Search, + SearchDescription = SearchDescription + }; +} + +public static class MemberListOptionsExt +{ + public static IEnumerable SortByMemberListOptions(this IEnumerable input, + MemberListOptions opts, LookupContext ctx) { - Name, - DisplayName, - Hid, - MessageCount, - CreationDate, - LastSwitch, - LastMessage, - Birthdate, - Random - } + IComparer ReverseMaybe(IComparer c) => + opts.Reverse ? Comparer.Create((a, b) => c.Compare(b, a)) : c; - public enum ListType - { - Short, - Long + 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 => 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) + .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); } -} \ No newline at end of file +} + +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 e600bcc6..c6bb978e 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -1,9 +1,5 @@ -using System; -using System.Threading.Tasks; using System.Net; -using System.Net.Http; using System.Web; -using System.Linq; using Dapper; @@ -13,131 +9,137 @@ using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.Bot -{ - public class Member - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; - private readonly HttpClient _client; - private readonly DispatchService _dispatch; +namespace PluralKit.Bot; - public Member(EmbedService embeds, IDatabase db, ModelRepository repo, HttpClient client, DispatchService dispatch) +public class Member +{ + private readonly HttpClient _client; + private readonly IDatabase _db; + private readonly DispatchService _dispatch; + private readonly EmbedService _embeds; + private readonly ModelRepository _repo; + + public Member(EmbedService embeds, IDatabase db, ModelRepository repo, HttpClient client, + DispatchService dispatch) + { + _embeds = embeds; + _db = db; + _repo = repo; + _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 _repo.GetMemberByName(ctx.System.Id, memberName); + if (existingMember != null) { - _embeds = embeds; - _db = db; - _repo = repo; - _client = client; - _dispatch = dispatch; + 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."); + await using var conn = await _db.Obtain(); - // Hard name length cap - if (memberName.Length > Limits.MaxMemberNameLength) - throw Errors.StringTooLongError("Member name", memberName.Length, Limits.MaxMemberNameLength); + // Enforce per-system member limit + var memberCount = await _repo.GetSystemMemberCount(ctx.System.Id); + var memberLimit = ctx.System.MemberLimitOverride ?? Limits.MaxMemberCount; + if (memberCount >= memberLimit) + throw Errors.MemberLimitReachedError(memberLimit); - // Warn if there's already a member by this name - var existingMember = await _repo.GetMemberByName(ctx.System.Id, memberName); - if (existingMember != null) + // Create the member + var member = await _repo.CreateMember(ctx.System.Id, memberName); + memberCount++; + + // Try to match an image attached to the message + var avatarArg = ctx.Message.Attachments.FirstOrDefault(); + Exception imageMatchError = null; + var sentDispatch = false; + if (avatarArg != null) + try { - 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."); - } + await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Url); + await _db.Execute(conn => + _repo.UpdateMember(member.Id, new MemberPatch { AvatarUrl = avatarArg.Url }, conn)); - await using var conn = await _db.Obtain(); - - // Enforce per-system member limit - var memberCount = await _repo.GetSystemMemberCount(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(ctx.System.Id, memberName); - memberCount++; - - // Try to match an image attached to the message - var avatarArg = ctx.Message.Attachments.FirstOrDefault(); - Exception imageMatchError = null; - bool sentDispatch = false; - if (avatarArg != null) - { - try - { - await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Url); - await _db.Execute(conn => _repo.UpdateMember(member.Id, new MemberPatch { AvatarUrl = avatarArg.Url }, conn)); - - _ = _dispatch.Dispatch(member.Id, new() - { - Event = DispatchEvent.CREATE_MEMBER, - EventData = JObject.FromObject(new { name = memberName, avatar_url = avatarArg.Url }), - }); - sentDispatch = true; - } - catch (Exception e) - { - imageMatchError = e; - } - } - - if (!sentDispatch) - _ = _dispatch.Dispatch(member.Id, new() + _ = _dispatch.Dispatch(member.Id, new UpdateDispatchData { Event = DispatchEvent.CREATE_MEMBER, - EventData = JObject.FromObject(new { name = memberName }), + EventData = JObject.FromObject(new { name = memberName, avatar_url = avatarArg.Url }), }); + sentDispatch = true; + } + catch (Exception e) + { + imageMatchError = e; + } - // 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 _db.Execute(conn => conn.QuerySingleAsync("select has_private_members(@System)", + if (!sentDispatch) + _ = _dispatch.Dispatch(member.Id, new UpdateDispatchData + { + Event = DispatchEvent.CREATE_MEMBER, + EventData = JObject.FromObject(new { name = memberName }), + }); + + // 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 _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 (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."); - } + 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 _repo.GetSystem(target.System); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system))); - } + public async Task ViewMember(Context ctx, PKMember target) + { + var system = await _repo.GetSystem(target.System); + await ctx.Reply( + embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system))); + } - public async Task Soulscream(Context ctx, PKMember target) - { - // this is for a meme, please don't take this code seriously. :) + 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 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 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 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()); - } + 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 8aa60a3c..29312cc3 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -1,164 +1,170 @@ #nullable enable -using System; -using System.Net.Http; -using System.Threading.Tasks; - 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; + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public MemberAvatar(IDatabase db, ModelRepository repo, HttpClient client) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly HttpClient _client; + _db = db; + _repo = repo; + _client = client; + } - public MemberAvatar(IDatabase db, ModelRepository repo, HttpClient client) + 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; - _client = client; - } - - 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 EmbedBuilder() - .Title($"{target.NameFor(ctx)}'s {field}") - .Image(new(currentValue?.TryGetCleanCdnUrl())); - if (target.System == ctx.System?.Id) - eb.Description($"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 _repo.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 _repo.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, embed: new EmbedBuilder().Image(new(avatar.Url)).Build()) - : ctx.Reply(msg); - } - - private Task UpdateAvatar(AvatarLocation location, Context ctx, PKMember target, string? url) - { - switch (location) - { - case AvatarLocation.Server: - return _repo.UpdateMemberGuild(target.Id, ctx.Guild.Id, new() { AvatarUrl = url }); - case AvatarLocation.Member: - return _repo.UpdateMember(target.Id, new() { AvatarUrl = url }); - 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()} 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) + { + 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 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()} {cmd} clear`."); + await ctx.Reply(embed: eb.Build()); + } + + public async Task ServerAvatar(Context ctx, PKMember target) + { + ctx.CheckGuildContext(); + var guildData = await _repo.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 _repo.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 _repo.UpdateMemberGuild(target.Id, ctx.Guild.Id, new MemberGuildPatch { AvatarUrl = url }); + case AvatarLocation.Member: + return _repo.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 1a5e8053..d69d62f4 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -1,609 +1,700 @@ using System.Text.RegularExpressions; -using System.Threading.Tasks; -using System; -using System.Net.Http; using Myriad.Builders; +using Myriad.Types; using NodaTime; using NodaTime.Extensions; using PluralKit.Core; -namespace PluralKit.Bot -{ - public class MemberEdit - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly HttpClient _client; +namespace PluralKit.Bot; - public MemberEdit(IDatabase db, ModelRepository repo, HttpClient client) +public class MemberEdit +{ + private readonly HttpClient _client; + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public MemberEdit(IDatabase db, ModelRepository repo, HttpClient client) + { + _db = db; + _repo = repo; + _client = client; + } + + 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 _repo.GetMemberByName(ctx.System.Id, newName); + if (existingMember != null && existingMember.Id != target.Id) { - _db = db; - _repo = repo; - _client = client; + 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 _repo.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 memberGuildConfig = await _repo.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."); + } + } - var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new name for the member."); + public async Task Description(Context ctx, PKMember target) + { + 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()} description `."; - // Hard name length cap - if (newName.Length > Limits.MaxMemberNameLength) - throw Errors.StringTooLongError("Member name", newName.Length, Limits.MaxMemberNameLength); + if (!target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) + throw Errors.LookupNotAllowed; - // Warn if there's already a member by this name - var existingMember = await _repo.GetMemberByName(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, "Rename")) throw new PKError("Member renaming cancelled."); - } + if (ctx.MatchRaw()) + { + if (target.Description == null) + await ctx.Reply(noDescriptionSetMessage); + else + await ctx.Reply($"```\n{target.Description}\n```"); + return; + } - // Rename the member - var patch = new MemberPatch { Name = Partial.Present(newName) }; + 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()} description -raw`." + + (ctx.System?.Id == target.System + ? $" To clear it, type `pk;member {target.Reference()} description -clear`." + : ""))) + .Build()); + return; + } + + ctx.CheckOwnMember(target); + + if (await ctx.MatchClear("this member's description")) + { + var patch = new MemberPatch { Description = Partial.Null() }; + await _repo.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 _repo.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."); + 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()} pronouns `."; + + if (!target.PronounPrivacy.CanAccess(ctx.LookupContextFor(target.System))) + throw Errors.LookupNotAllowed; + + 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()} pronouns -raw`." + + (ctx.System?.Id == target.System + ? $" To clear them, type `pk;member {target.Reference()} pronouns -clear`." + : "")); + return; + } + + ctx.CheckOwnMember(target); + + if (await ctx.MatchClear("this member's pronouns")) + { + var patch = new MemberPatch { Pronouns = Partial.Null() }; + await _repo.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 _repo.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 _repo.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 _repo.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 _repo.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()} 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()} 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 _repo.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 _repo.UpdateMember(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(); + + LocalDate? birthday; + if (birthdayStr == "today" || birthdayStr == "now") + birthday = SystemClock.Instance.InZone(ctx.System.Zone).GetCurrentDate(); + else + birthday = DateUtils.ParseDate(birthdayStr, true); + + if (birthday == null) throw Errors.BirthdayParseError(birthdayStr); + + var patch = new MemberPatch { Birthday = Partial.Present(birthday) }; + await _repo.UpdateMember(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 _repo.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.")); + + if (target.DisplayName == null && memberGuildConfig?.DisplayName == null) + eb.Field(new Embed.Field("Name", $"**{target.NameFor(ctx)}**")); + else + eb.Field(new Embed.Field("Name", target.NameFor(ctx))); + + if (target.NamePrivacy.CanAccess(lcx)) + { + if (target.DisplayName != null && memberGuildConfig?.DisplayName == null) + eb.Field(new Embed.Field("Display Name", $"**{target.DisplayName}**")); + else + eb.Field(new Embed.Field("Display Name", target.DisplayName ?? "*(none)*")); + } + + if (ctx.Guild != null) + { + if (memberGuildConfig?.DisplayName != null) + eb.Field(new Embed.Field($"Server Name (in {ctx.Guild.Name})", + $"**{memberGuildConfig.DisplayName}**")); + else + eb.Field(new Embed.Field($"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 _repo.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."); + 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); } - public async Task Description(Context ctx, PKMember target) + 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()} displayname `."; + + // No perms check, display name isn't covered by member privacy + + if (ctx.MatchRaw()) { - var noDescriptionSetMessage = "This member does not have a description set."; + 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); if (ctx.System?.Id == target.System) - noDescriptionSetMessage += $" To set one, type `pk;member {target.Reference()} description `."; - - if (!target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) - throw Errors.LookupNotAllowed; - - 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("\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()); - return; - } - - ctx.CheckOwnMember(target); - - if (await ctx.MatchClear("this member's description")) - { - var patch = new MemberPatch { Description = Partial.Null() }; - await _repo.UpdateMember(target.Id, patch); - await ctx.Reply($"{Emojis.Success} Member description cleared."); - } - else - { - var description = ctx.RemainderOrNull(skipFlags: false).NormalizeLineEndSpacing(); - if (description.IsLongerThan(Limits.MaxDescriptionLength)) - throw Errors.StringTooLongError("Description", description.Length, Limits.MaxDescriptionLength); - - var patch = new MemberPatch { Description = Partial.Present(description) }; - await _repo.UpdateMember(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Member description changed."); - } + eb.Description( + $"To change display name, type `pk;member {target.Reference()} displayname `." + + $"To clear it, type `pk;member {target.Reference()} displayname -clear`." + + $"To print the raw display name, type `pk;member {target.Reference()} displayname -raw`."); + await ctx.Reply(embed: eb.Build()); + return; } - public async Task Pronouns(Context ctx, PKMember target) + ctx.CheckOwnMember(target); + + if (await ctx.MatchClear("this member's display name")) { - var noPronounsSetMessage = "This member does not have pronouns set."; - if (ctx.System?.Id == target.System) - noPronounsSetMessage += $"To set some, type `pk;member {target.Reference()} pronouns `."; - - if (!target.PronounPrivacy.CanAccess(ctx.LookupContextFor(target.System))) - throw Errors.LookupNotAllowed; - - 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()} pronouns -raw`." - + (ctx.System?.Id == target.System ? $" To clear them, type `pk;member {target.Reference()} pronouns -clear`." : "")); - return; - } - - ctx.CheckOwnMember(target); - - if (await ctx.MatchClear("this member's pronouns")) - { - var patch = new MemberPatch { Pronouns = Partial.Null() }; - await _repo.UpdateMember(target.Id, patch); - await ctx.Reply($"{Emojis.Success} Member pronouns cleared."); - } - else - { - var pronouns = ctx.RemainderOrNull(skipFlags: false).NormalizeLineEndSpacing(); - if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) - throw Errors.StringTooLongError("Pronouns", pronouns.Length, Limits.MaxPronounsLength); - - var patch = new MemberPatch { Pronouns = Partial.Present(pronouns) }; - await _repo.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 _repo.UpdateMember(target.Id, new() { BannerImage = null }); - await ctx.Reply($"{Emojis.Success} Member banner image cleared."); - } - - async Task SetBannerImage(ParsedImage img) - { - await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, isFullSizeImage: true); - - await _repo.UpdateMember(target.Id, new() { 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, embed: new EmbedBuilder().Image(new(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(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 _repo.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()} 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($"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()} 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 _repo.UpdateMember(target.Id, patch); - - await ctx.Reply(embed: new EmbedBuilder() - .Title($"{Emojis.Success} Member color changed.") - .Color(color.ToDiscordColor()) - .Thumbnail(new($"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 _repo.UpdateMember(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(); - - LocalDate? birthday; - if (birthdayStr == "today" || birthdayStr == "now") - birthday = SystemClock.Instance.InZone(ctx.System.Zone).GetCurrentDate(); - else - birthday = DateUtils.ParseDate(birthdayStr, true); - - if (birthday == null) throw Errors.BirthdayParseError(birthdayStr); - - var patch = new MemberPatch { Birthday = Partial.Present(birthday) }; - await _repo.UpdateMember(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 _repo.GetMemberGuild(ctx.Guild.Id, target.Id); - - var eb = new EmbedBuilder() - .Title($"Member names") - .Footer(new($"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.Field(new("Name", $"**{target.NameFor(ctx)}**")); - else - eb.Field(new("Name", target.NameFor(ctx))); - - if (target.NamePrivacy.CanAccess(lcx)) - { - if (target.DisplayName != null && memberGuildConfig?.DisplayName == null) - eb.Field(new("Display Name", $"**{target.DisplayName}**")); - else - eb.Field(new("Display Name", target.DisplayName ?? "*(none)*")); - } - - if (ctx.Guild != null) - { - if (memberGuildConfig?.DisplayName != null) - eb.Field(new($"Server Name (in {ctx.Guild.Name})", $"**{memberGuildConfig.DisplayName}**")); - else - eb.Field(new($"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 _repo.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()} 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); - if (ctx.System?.Id == target.System) - eb.Description($"To change display name, type `pk;member {target.Reference()} displayname `." - + $"To clear it, type `pk;member {target.Reference()} displayname -clear`." - + $"To print the raw display name, type `pk;member {target.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 _repo.UpdateMember(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 - { - var newDisplayName = ctx.RemainderOrNull(skipFlags: false).NormalizeLineEndSpacing(); - - var patch = new MemberPatch { DisplayName = Partial.Present(newDisplayName) }; - await _repo.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()} servername `."; - - // No perms check, display name isn't covered by member privacy - var memberGuildConfig = await _repo.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); - if (ctx.System?.Id == target.System) - eb.Description($"To change server name, type `pk;member {target.Reference()} servername `.\nTo clear it, type `pk;member {target.Reference()} servername -clear`.\nTo print the raw server name, type `pk;member {target.Reference()} servername -raw`."); - await ctx.Reply(embed: eb.Build()); - return; - } - - ctx.CheckOwnMember(target); - - if (await ctx.MatchClear("this member's server name")) - { - await _repo.UpdateMemberGuild(target.Id, ctx.Guild.Id, new() { 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(skipFlags: false).NormalizeLineEndSpacing(); - - await _repo.UpdateMemberGuild(target.Id, ctx.Guild.Id, new() { 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) }; + var patch = new MemberPatch { DisplayName = Partial.Null() }; await _repo.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."); + await PrintSuccess( + $"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\"."); } - - public async Task MemberAutoproxy(Context ctx, PKMember target) + else { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - 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) }; + var patch = new MemberPatch { DisplayName = Partial.Present(newDisplayName) }; await _repo.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("Name (replaces name with display name if member has one)", target.NamePrivacy.Explanation())) - .Field(new("Description", target.DescriptionPrivacy.Explanation())) - .Field(new("Avatar", target.AvatarPrivacy.Explanation())) - .Field(new("Birthday", target.BirthdayPrivacy.Explanation())) - .Field(new("Pronouns", target.PronounPrivacy.Explanation())) - .Field(new("Meta (message count, last front, last message)", target.MetadataPrivacy.Explanation())) - .Field(new("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 _repo.GetMemberGuild(ctx.Guild.Id, target.Id); - - async Task SetAll(PrivacyLevel level) - { - await _repo.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 _repo.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()} 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 _repo.DeleteMember(target.Id); - - await ctx.Reply($"{Emojis.Success} Member deleted."); + 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()} servername `."; + + // No perms check, display name isn't covered by member privacy + var memberGuildConfig = await _repo.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); + if (ctx.System?.Id == target.System) + eb.Description( + $"To change server name, type `pk;member {target.Reference()} servername `.\nTo clear it, type `pk;member {target.Reference()} servername -clear`.\nTo print the raw server name, type `pk;member {target.Reference()} servername -raw`."); + await ctx.Reply(embed: eb.Build()); + return; + } + + ctx.CheckOwnMember(target); + + if (await ctx.MatchClear("this member's server name")) + { + await _repo.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 _repo.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 _repo.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; + + bool newValue; + // todo: MatchToggle + 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 _repo.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 _repo.GetMemberGuild(ctx.Guild.Id, target.Id); + + async Task SetAll(PrivacyLevel level) + { + await _repo.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 _repo.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()} 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 _repo.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 index 90bc1f17..59b3a1d8 100644 --- a/PluralKit.Bot/Commands/MemberGroup.cs +++ b/PluralKit.Bot/Commands/MemberGroup.cs @@ -1,87 +1,87 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - using Myriad.Builders; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class MemberGroup { - public class MemberGroup + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public MemberGroup(IDatabase db, ModelRepository repo) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _db = db; + _repo = repo; + } - public MemberGroup(IDatabase db, ModelRepository 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(); + + var existingGroups = (await _repo.GetMemberGroups(target.Id).ToListAsync()) + .Select(g => g.Id) + .Distinct() + .ToList(); + + List toAction; + + if (op == Groups.AddRemoveOperation.Add) { - _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() + toAction = groups + .Where(group => !existingGroups.Contains(group)) .ToList(); - var existingGroups = (await _repo.GetMemberGroups(target.Id).ToListAsync()) - .Select(g => g.Id) - .Distinct() + await _repo.AddGroupsToMember(target.Id, toAction); + } + else if (op == Groups.AddRemoveOperation.Remove) + { + toAction = groups + .Where(group => existingGroups.Contains(group)) .ToList(); - List toAction; - - if (op == Groups.AddRemoveOperation.Add) - { - toAction = groups - .Where(group => !existingGroups.Contains(group)) - .ToList(); - - await _repo.AddGroupsToMember(target.Id, toAction); - } - else if (op == Groups.AddRemoveOperation.Remove) - { - toAction = groups - .Where(group => existingGroups.Contains(group)) - .ToList(); - - await _repo.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)); + await _repo.RemoveGroupsFromMember(target.Id, toAction); } - - public async Task List(Context ctx, PKMember target) + else { - var pctx = ctx.LookupContextFor(target.System); - - var groups = await _repo.GetMemberGroups(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, (new EmbedBuilder().Title($"{target.Name}'s groups").Description(description)).Build()); + return; // otherwise toAction "may be unassigned" } + + await ctx.Reply(GroupMemberUtils.GenerateResponse(op, 1, groups.Count, toAction.Count, + groups.Count - toAction.Count)); + } + + public async Task List(Context ctx, PKMember target) + { + var pctx = ctx.LookupContextFor(target.System); + + var groups = await _repo.GetMemberGroups(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, new EmbedBuilder().Title($"{target.Name}'s groups").Description(description).Build()); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs index 0651445f..1ed5c58b 100644 --- a/PluralKit.Bot/Commands/MemberProxy.cs +++ b/PluralKit.Bot/Commands/MemberProxy.cs @@ -1,137 +1,136 @@ -using System.Linq; -using System.Threading.Tasks; - using Dapper; using PluralKit.Core; -namespace PluralKit.Bot -{ - public class MemberProxy - { - private readonly IDatabase _db; - private readonly ModelRepository _repo; +namespace PluralKit.Bot; - public MemberProxy(IDatabase db, ModelRepository repo) +public class MemberProxy +{ + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public MemberProxy(IDatabase db, ModelRepository repo) + { + _db = db; + _repo = repo; + } + + public async Task Proxy(Context ctx, PKMember target) + { + 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 _db.Execute(conn => conn.QueryAsync(query, + new { newTag.Prefix, newTag.Suffix, Existing = target.Id, system = target.System }))).ToList(); - ProxyTag ParseProxyTags(string exampleProxy) + 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, "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) { - // // 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]); - } - - 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 is not distinct from @Prefix and suffix is not distinct from @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; - - 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) - { - 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 patch = new MemberPatch { ProxyTags = Partial.Present(new ProxyTag[0]) }; - await _repo.UpdateMember(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 (tagToAdd.ProxyString.Length > Limits.MaxProxyTagLength) - throw new PKError($"Proxy tag too long ({tagToAdd.ProxyString.Length} > {Limits.MaxProxyTagLength} characters)."); - - 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 _repo.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(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 _repo.UpdateMember(target.Id, patch); - var newTags = target.ProxyTags.ToList(); - newTags.Remove(tagToRemove); - var patch = new MemberPatch { ProxyTags = Partial.Present(newTags.ToArray()) }; - await _repo.UpdateMember(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); + 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 _repo.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); + 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 _repo.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); + + // 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, "Replace")) - 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 _repo.UpdateMember(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 _repo.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 index a767d478..e668251a 100644 --- a/PluralKit.Bot/Commands/Message.cs +++ b/PluralKit.Bot/Commands/Message.cs @@ -1,246 +1,258 @@ #nullable enable -using System.IO; using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; 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.Rest.Exceptions; using Myriad.Types; using NodaTime; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class ProxiedMessage { - public class ProxiedMessage + private static readonly Duration EditTimeout = Duration.FromMinutes(10); + private readonly IDiscordCache _cache; + private readonly IClock _clock; + + private readonly IDatabase _db; + private readonly EmbedService _embeds; + private readonly LogChannelService _logChannel; + private readonly ModelRepository _repo; + private readonly DiscordApiClient _rest; + private readonly WebhookExecutorService _webhookExecutor; + + public ProxiedMessage(IDatabase db, ModelRepository repo, EmbedService embeds, IClock clock, + DiscordApiClient rest, + WebhookExecutorService webhookExecutor, LogChannelService logChannel, IDiscordCache cache) { - private static readonly Duration EditTimeout = Duration.FromMinutes(10); + _db = db; + _repo = repo; + _embeds = embeds; + _clock = clock; + _rest = rest; + _webhookExecutor = webhookExecutor; + _logChannel = logChannel; + _cache = cache; + } - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; - private readonly IClock _clock; - private readonly DiscordApiClient _rest; - private readonly WebhookExecutorService _webhookExecutor; - private readonly LogChannelService _logChannel; - private readonly IDiscordCache _cache; + public async Task EditMessage(Context ctx) + { + var msg = await GetMessageToEdit(ctx); + if (!ctx.HasNext()) + throw new PKSyntaxError("You need to include the message to edit in."); - public ProxiedMessage(IDatabase db, ModelRepository repo, EmbedService embeds, IClock clock, DiscordApiClient rest, - WebhookExecutorService webhookExecutor, LogChannelService logChannel, IDiscordCache cache) + if (ctx.System.Id != msg.System.Id) + throw new PKError("Can't edit a message sent by a different system."); + + var newContent = ctx.RemainderOrNull().NormalizeLineEndSpacing(); + + var originalMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid); + if (originalMsg == null) + throw new PKError("Could not edit message."); + + try { - _db = db; - _repo = repo; - _embeds = embeds; - _clock = clock; - _rest = rest; - _webhookExecutor = webhookExecutor; - _logChannel = logChannel; - _cache = cache; + 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!); } - - public async Task EditMessage(Context ctx) + catch (NotFoundException) { - var msg = await GetMessageToEdit(ctx); - if (!ctx.HasNext()) - throw new PKSyntaxError("You need to include the message to edit in."); - - if (ctx.System.Id != msg.System.Id) - throw new PKError("Can't edit a message sent by a different system."); - - var newContent = ctx.RemainderOrNull().NormalizeLineEndSpacing(); - - var originalMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid); - if (originalMsg == null) - throw new PKError("Could not edit message."); - - 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() { 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) - { - await using var conn = await _db.Obtain(); - FullMessage? msg = null; - - var (referencedMessage, _) = ctx.MatchMessage(false); - if (referencedMessage != null) - { - msg = await _repo.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 PKError("You must use a message link to edit messages in DMs."); - - var recent = await FindRecentMessage(ctx); - if (recent == null) - throw new PKError("Could not find a recent message to edit."); - - msg = await _repo.GetMessage(conn, recent.Mid); - if (msg == null) - throw new PKError("Could not find a recent message to edit."); - } - - 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 _cache.GetChannel(msg.Message.Channel); - if (channel == null) - throw new PKError(error); - - if (!await ctx.CheckPermissionsInGuildChannel(channel, - PermissionSet.ViewChannel | PermissionSet.SendMessages - )) - throw new PKError(error); - } - - return msg; - } - - private async Task FindRecentMessage(Context ctx) - { - var lastMessage = await _repo.GetLastMessage(ctx.Guild.Id, ctx.Channel.Id, ctx.Author.Id); - if (lastMessage == null) - return null; - - var timestamp = DiscordUtils.SnowflakeToInstant(lastMessage.Mid); - if (_clock.GetCurrentInstant() - timestamp > EditTimeout) - 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 _db.Execute(c => _repo.GetMessage(c, messageId.Value)); - if (message == null) - { - if (isDelete) - { - await DeleteCommandMessage(ctx, messageId.Value); - return; - } - else - throw Errors.MessageNotFound(messageId.Value); - } - - var showContent = true; - var noShowContentError = "Message deleted or inaccessible."; - - var channel = await _cache.GetChannel(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(text: $"```{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) - 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() { Name = Emojis.Success }); - - return; - } - if (ctx.Match("author") || ctx.MatchFlag("author")) - { - var user = await _cache.GetOrFetchUser(_rest, message.Message.Sender); - var eb = new EmbedBuilder() - .Author(new(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})*", embed: eb.Build()); - return; - } - - await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent)); - } - - private async Task DeleteCommandMessage(Context ctx, ulong messageId) - { - var message = await _repo.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() { Name = Emojis.Success }); + throw new PKError("Could not edit message."); } } + + private async Task GetMessageToEdit(Context ctx) + { + await using var conn = await _db.Obtain(); + FullMessage? msg = null; + + var (referencedMessage, _) = ctx.MatchMessage(false); + if (referencedMessage != null) + { + msg = await _repo.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 PKError("You must use a message link to edit messages in DMs."); + + var recent = await FindRecentMessage(ctx); + if (recent == null) + throw new PKError("Could not find a recent message to edit."); + + msg = await _repo.GetMessage(conn, recent.Mid); + if (msg == null) + throw new PKError("Could not find a recent message to edit."); + } + + 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 _cache.GetChannel(msg.Message.Channel); + if (channel == null) + throw new PKError(error); + + if (!await ctx.CheckPermissionsInGuildChannel(channel, + PermissionSet.ViewChannel | PermissionSet.SendMessages + )) + throw new PKError(error); + } + + return msg; + } + + private async Task FindRecentMessage(Context ctx) + { + var lastMessage = await _repo.GetLastMessage(ctx.Guild.Id, ctx.Channel.Id, ctx.Author.Id); + if (lastMessage == null) + return null; + + var timestamp = DiscordUtils.SnowflakeToInstant(lastMessage.Mid); + if (_clock.GetCurrentInstant() - timestamp > EditTimeout) + 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 _db.Execute(c => _repo.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 _cache.GetChannel(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) + 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 _cache.GetOrFetchUser(_rest, 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 _repo.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 20613399..1d4b60f2 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -1,124 +1,134 @@ -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; using App.Metrics; -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 Myriad.Builders; -using Myriad.Cache; -using Myriad.Extensions; -using Myriad.Gateway; -using Myriad.Rest; -using Myriad.Rest.Exceptions; -using Myriad.Rest.Types.Requests; -using Myriad.Types; +namespace PluralKit.Bot; -namespace PluralKit.Bot +public class Misc { - public class Misc + private readonly Bot _bot; + private readonly BotConfig _botConfig; + private readonly IDiscordCache _cache; + private readonly Cluster _cluster; + private readonly CpuStatService _cpu; + private readonly IDatabase _db; + private readonly EmbedService _embeds; + private readonly ProxyMatcher _matcher; + private readonly IMetrics _metrics; + private readonly ProxyService _proxy; + private readonly ModelRepository _repo; + private readonly DiscordApiClient _rest; + private readonly ShardInfoService _shards; + + public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards, + EmbedService embeds, ModelRepository repo, IDatabase db, IDiscordCache cache, + DiscordApiClient rest, Bot bot, Cluster cluster, ProxyService proxy, ProxyMatcher matcher) { - 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; - private readonly IDiscordCache _cache; - private readonly DiscordApiClient _rest; - private readonly Cluster _cluster; - private readonly Bot _bot; - private readonly ProxyService _proxy; - private readonly ProxyMatcher _matcher; + _botConfig = botConfig; + _metrics = metrics; + _cpu = cpu; + _shards = shards; + _embeds = embeds; + _repo = repo; + _db = db; + _cache = cache; + _rest = rest; + _bot = bot; + _cluster = cluster; + _proxy = proxy; + _matcher = matcher; + } - public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards, EmbedService embeds, ModelRepository repo, - IDatabase db, IDiscordCache cache, DiscordApiClient rest, Bot bot, Cluster cluster, ProxyService proxy, ProxyMatcher matcher) - { - _botConfig = botConfig; - _metrics = metrics; - _cpu = cpu; - _shards = shards; - _embeds = embeds; - _repo = repo; - _db = db; - _cache = cache; - _rest = rest; - _bot = bot; - _cluster = cluster; - _proxy = proxy; - _matcher = matcher; - } + public async Task Invite(Context ctx) + { + var clientId = _botConfig.ClientId ?? await _cache.GetOwnUser(); - public async Task Invite(Context ctx) - { - var clientId = _botConfig.ClientId ?? await _cache.GetOwnUser(); + var permissions = + PermissionSet.AddReactions | + PermissionSet.AttachFiles | + PermissionSet.EmbedLinks | + PermissionSet.ManageMessages | + PermissionSet.ManageWebhooks | + PermissionSet.ReadMessageHistory | + PermissionSet.SendMessages; - var permissions = - PermissionSet.AddReactions | - PermissionSet.AttachFiles | - PermissionSet.EmbedLinks | - PermissionSet.ManageMessages | - PermissionSet.ManageWebhooks | - PermissionSet.ReadMessageHistory | - PermissionSet.SendMessages; + var invite = + $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot%20applications.commands&permissions={(ulong)permissions}"; + await ctx.Reply($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>"); + } - var invite = $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot%20applications.commands&permissions={(ulong)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; - 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; - 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; + var counts = await _repo.GetStats(); - var counts = await _repo.GetStats(); + var shardId = ctx.Shard.ShardId; + var shardTotal = ctx.Cluster.Shards.Count; + var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count(); + var shardInfo = _shards.GetShardInfo(ctx.Shard); - var shardId = ctx.Shard.ShardId; - var shardTotal = ctx.Cluster.Shards.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 process = Process.GetCurrentProcess(); - var memoryUsage = process.WorkingSet64; + var now = SystemClock.Instance.GetCurrentInstant(); + var shardUptime = now - shardInfo.LastConnectionTime; - var now = SystemClock.Instance.GetCurrentInstant(); - var shardUptime = now - shardInfo.LastConnectionTime; - - var embed = new EmbedBuilder(); - if (messagesReceived != null) embed.Field(new("Messages processed", $"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", true)); - if (messagesProxied != null) embed.Field(new("Messages proxied", $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", true)); - if (commandsRun != null) embed.Field(new("Commands executed", $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", true)); - - embed - .Field(new("Current shard", $"Shard #{shardId} (of {shardTotal} total, {shardUpTotal} are up)", true)) - .Field(new("Shard uptime", $"{shardUptime.FormatDuration()} ({shardInfo.DisconnectionCount} disconnections)", true)) - .Field(new("CPU usage", $"{_cpu.LastCpuMeasure:P1}", true)) - .Field(new("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true)) - .Field(new("Latency", $"API: {apiLatency.TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency.Milliseconds} ms", true)) - .Field(new("Total numbers", $"{counts.SystemCount:N0} systems," - + $" {counts.MemberCount:N0} members," - + $" {counts.GroupCount:N0} groups," - + $" {counts.SwitchCount:N0} switches," - + $" {counts.MessageCount:N0} messages")) - .Timestamp(process.StartTime.ToString("O")) - .Footer(new($"PluralKit {BuildInfoService.Version} • https://github.com/xSke/PluralKit • Last restarted: ")); ; - await ctx.Rest.EditMessage(msg.ChannelId, msg.Id, - new MessageEditRequest { Content = "", Embed = embed.Build() }); - } + var embed = new EmbedBuilder(); + if (messagesReceived != null) + embed.Field(new Embed.Field("Messages processed", + $"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", + true)); + if (messagesProxied != null) + embed.Field(new Embed.Field("Messages proxied", + $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", + true)); + if (commandsRun != null) + embed.Field(new Embed.Field("Commands executed", + $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", + true)); + embed + .Field(new Embed.Field("Current shard", + $"Shard #{shardId} (of {shardTotal} total, {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.ShardLatency.Milliseconds} ms", + true)) + .Field(new Embed.Field("Total numbers", $" {counts.SystemCount:N0} systems," + + $" {counts.MemberCount:N0} members," + + $" {counts.GroupCount:N0} groups," + + $" {counts.SwitchCount:N0} switches," + + $" {counts.MessageCount:N0} messages")) + .Timestamp(process.StartTime.ToString("O")) + .Footer(new Embed.EmbedFooter( + $"PluralKit {BuildInfoService.Version} • https://github.com/xSke/PluralKit • Last restarted: ")); + ; + await ctx.Rest.EditMessage(msg.ChannelId, msg.Id, + new MessageEditRequest { Content = "", Embed = embed.Build() }); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs b/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs index 99ebe745..fe55503f 100644 --- a/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs +++ b/PluralKit.Bot/Commands/Privacy/ContextPrivacyExt.cs @@ -1,57 +1,60 @@ using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public static class ContextPrivacyExt { - public static class ContextPrivacyExt + public static PrivacyLevel PopPrivacyLevel(this Context ctx) { - public static PrivacyLevel PopPrivacyLevel(this Context ctx) - { - if (ctx.Match("public", "show", "shown", "visible")) - return PrivacyLevel.Public; + if (ctx.Match("public", "show", "shown", "visible")) + return PrivacyLevel.Public; - if (ctx.Match("private", "hide", "hidden")) - return PrivacyLevel.Private; + if (ctx.Match("private", "hide", "hidden")) + return PrivacyLevel.Private; - if (!ctx.HasNext()) - throw new PKSyntaxError("You must pass a privacy level (`public` or `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`)."); - } + 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`)."); + 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; - } + 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`)."); + 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; - } + 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`)."); + 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; - } + 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; + 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; - } + return privacy; } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Random.cs b/PluralKit.Bot/Commands/Random.cs index ff761f38..74e6636f 100644 --- a/PluralKit.Bot/Commands/Random.cs +++ b/PluralKit.Bot/Commands/Random.cs @@ -1,77 +1,77 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Random { - public class Random + private readonly IDatabase _db; + private readonly EmbedService _embeds; + private readonly ModelRepository _repo; + + private readonly global::System.Random randGen = new(); + + public Random(EmbedService embeds, IDatabase db, ModelRepository repo) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; + _embeds = embeds; + _db = db; + _repo = repo; + } - 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 _repo.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 _repo.GetSystemMembers(ctx.System.Id).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 (!ctx.MatchFlag("all", "a")) - members = members.Where(m => m.MemberVisibility == PrivacyLevel.Public).ToList(); + var randInt = randGen.Next(members.Count); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, + ctx.LookupContextFor(ctx.System))); + } - if (members == null || !members.Any()) - throw new PKError("Your system has no members! Please create at least one member before using this command."); + public async Task Group(Context ctx) + { + ctx.CheckSystem(); - var randInt = randGen.Next(members.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); - } + var groups = await _db.Execute(c => c.QueryGroupList(ctx.System.Id)); + if (!ctx.MatchFlag("all", "a")) + groups = groups.Where(g => g.Visibility == PrivacyLevel.Public); - public async Task Group(Context ctx) - { - ctx.CheckSystem(); + if (groups == null || !groups.Any()) + throw new PKError( + "Your system has no groups! Please create at least one group before using this command."); - 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 randInt = randGen.Next(groups.Count()); + await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, ctx.System, groups.ToArray()[randInt])); + } - if (groups == null || !groups.Any()) - throw new PKError("Your system has no groups! Please create at least one group before using this command."); + public async Task GroupMember(Context ctx, PKGroup group) + { + var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(group.System)); + opts.GroupFilter = group.Id; - var randInt = randGen.Next(groups.Count()); - await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, ctx.System, groups.ToArray()[randInt])); - } + await using var conn = await _db.Obtain(); + var members = await conn.QueryMemberList(ctx.System.Id, opts.ToQueryOptions()); - public async Task GroupMember(Context ctx, PKGroup group) - { - var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(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."); - await using var conn = await _db.Obtain(); - var members = await 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 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))); - } + var randInt = randGen.Next(ms.Count); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.Guild, + ctx.LookupContextFor(ctx.System))); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index 3ec6e4ad..627b6e85 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; using Myriad.Builders; using Myriad.Cache; @@ -10,206 +7,224 @@ using Myriad.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class ServerConfig { - public class ServerConfig + private readonly Bot _bot; + private readonly IDiscordCache _cache; + private readonly LoggerCleanService _cleanService; + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public ServerConfig(LoggerCleanService cleanService, IDatabase db, ModelRepository repo, IDiscordCache cache, + Bot bot) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly IDiscordCache _cache; - private readonly LoggerCleanService _cleanService; - private readonly Bot _bot; - public ServerConfig(LoggerCleanService cleanService, IDatabase db, ModelRepository repo, IDiscordCache cache, Bot bot) + _cleanService = cleanService; + _db = db; + _repo = repo; + _cache = cache; + _bot = bot; + } + + public async Task SetLogChannel(Context ctx) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + var settings = await _repo.GetGuild(ctx.Guild.Id); + + if (await ctx.MatchClear("the server log channel")) { - _cleanService = cleanService; - _db = db; - _repo = repo; - _cache = cache; - _bot = bot; + await _repo.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()) { - await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - var settings = await _repo.GetGuild(ctx.Guild.Id); - - if (await ctx.MatchClear("the server log channel")) + if (settings.LogChannel == null) { - await _repo.UpdateGuild(ctx.Guild.Id, new() { 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()) - { - if (settings.LogChannel == null) - { - await ctx.Reply("This server does not have a log channel set."); - return; - } + await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>."); + return; + } - await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>."); - return; + 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 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 _repo.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(); + if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); + affectedChannels.Add(channel); } - 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."); + ulong? logChannel = null; + var config = await _repo.GetGuild(ctx.Guild.Id); + logChannel = config.LogChannel; - 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."); + var blacklist = config.LogBlacklist.ToHashSet(); + if (enable) + blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); + else + blacklist.UnionWith(affectedChannels.Select(c => c.Id)); - await _repo.UpdateGuild(ctx.Guild.Id, new() { LogChannel = channel.Id }); - await ctx.Reply($"{Emojis.Success} Proxy logging channel set to <#{channel.Id}>."); - } + await _repo.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() }); - public async Task SetLogEnabled(Context ctx, bool enable) - { - await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + 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 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); - } + public async Task ShowBlacklisted(Context ctx) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - ulong? logChannel = null; - var config = await _repo.GetGuild(ctx.Guild.Id); - logChannel = config.LogChannel; + var blacklist = await _repo.GetGuild(ctx.Guild.Id); - var blacklist = config.LogBlacklist.ToHashSet(); - if (enable) - blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); - else - blacklist.UnionWith(affectedChannels.Select(c => c.Id)); - - await _repo.UpdateGuild(ctx.Guild.Id, new() { 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 _repo.GetGuild(ctx.Guild.Id); - - // Resolve all channels from the cache and order by position - var channels = (await Task.WhenAll(blacklist.Blacklist + // 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(); + .Where(c => c != null) + .OrderBy(c => c.Position) + .ToList(); - if (channels.Count == 0) + if (channels.Count == 0) + { + await ctx.Reply("This server has no blacklisted channels."); + return; + } + + 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}", - null, - async (eb, l) => + ulong? lastCategory = null; + + var fieldValue = new StringBuilder(); + foreach (var channel in l) { - async Task CategoryName(ulong? id) => - id != null ? (await _cache.GetChannel(id.Value)).Name : "(no category)"; - - ulong? lastCategory = null; - - var fieldValue = new StringBuilder(); - foreach (var channel in l) + if (lastCategory != channel!.ParentId && fieldValue.Length > 0) { - if (lastCategory != channel!.ParentId && fieldValue.Length > 0) - { - eb.Field(new(await CategoryName(lastCategory), fieldValue.ToString())); - fieldValue.Clear(); - } - else fieldValue.Append("\n"); - - fieldValue.Append(channel.Mention()); - lastCategory = channel.ParentId; + eb.Field(new Embed.Field(await CategoryName(lastCategory), fieldValue.ToString())); + fieldValue.Clear(); + } + else + { + fieldValue.Append("\n"); } - eb.Field(new(await CategoryName(lastCategory), fieldValue.ToString())); - }); - } - - 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 _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); + fieldValue.Append(channel.Mention()); + lastCategory = channel.ParentId; } - var guild = await _repo.GetGuild(ctx.Guild.Id); + eb.Field(new Embed.Field(await CategoryName(lastCategory), fieldValue.ToString())); + }); + } - var blacklist = guild.Blacklist.ToHashSet(); - if (shouldAdd) - blacklist.UnionWith(affectedChannels.Select(c => c.Id)); - else - blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); + public async Task SetBlacklisted(Context ctx, bool shouldAdd) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - await _repo.UpdateGuild(ctx.Guild.Id, new() { 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(", ", _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 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 eb = new EmbedBuilder() - .Title("Log cleanup settings") - .Field(new("Supported bots", botList)); - - var guildCfg = await _repo.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; + 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 _repo.UpdateGuild(ctx.Guild.Id, new() { LogCleanupEnabled = newValue }); + var guild = await _repo.GetGuild(ctx.Guild.Id); - 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."); + var blacklist = guild.Blacklist.ToHashSet(); + if (shouldAdd) + blacklist.UnionWith(affectedChannels.Select(c => c.Id)); + else + blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); + + await _repo.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(", ", _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 EmbedBuilder() + .Title("Log cleanup settings") + .Field(new Embed.Field("Supported bots", botList)); + + var guildCfg = await _repo.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 _repo.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 2aa425b7..52b4371a 100644 --- a/PluralKit.Bot/Commands/Switch.cs +++ b/PluralKit.Bot/Commands/Switch.cs @@ -1,204 +1,212 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - using NodaTime; using NodaTime.TimeZones; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Switch { - public class Switch + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public Switch(IDatabase db, ModelRepository repo) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _db = db; + _repo = repo; + } - public Switch(IDatabase db, ModelRepository 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; + 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 _db.Obtain(); + var lastSwitch = await _repo.GetLatestSwitch(ctx.System.Id); + if (lastSwitch != null) { - _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; - 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 _db.Obtain(); - var lastSwitch = await _repo.GetLatestSwitch(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); - - - 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(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 = _db.Execute(conn => _repo.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 _repo.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 _db.Obtain(); - var lastSwitch = await _repo.GetLatestSwitch(ctx.System.Id); - // Make sure there's at least one switch - if (lastSwitch == null) throw Errors.NoRegisteredSwitches; var lastSwitchMembers = _repo.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())) + // 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)); - - // 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 _repo.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) + 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); + + + 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(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 = _db.Execute(conn => _repo.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 _repo.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 _db.Obtain(); + var lastSwitch = await _repo.GetLatestSwitch(ctx.System.Id); + // Make sure there's at least one switch + if (lastSwitch == null) throw Errors.NoRegisteredSwitches; + var lastSwitchMembers = _repo.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)); + + // 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 _repo.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")) { - 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 _repo.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 _repo.GetSwitches(ctx.System.Id).Take(2).ToListAsync(); - if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches; - - var lastSwitchMembers = _db.Execute(conn => _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 = _db.Execute(conn => _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, "Delete Switch")) throw Errors.SwitchDeleteCancelled; - await _repo.DeleteSwitch(lastTwoSwitches[0].Id); - - await ctx.Reply($"{Emojis.Success} Switch deleted."); + // 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 _repo.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 _repo.GetSwitches(ctx.System.Id).Take(2).ToListAsync(); + if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches; + + var lastSwitchMembers = _db.Execute(conn => _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 = _db.Execute(conn => _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, "Delete Switch")) throw Errors.SwitchDeleteCancelled; + await _repo.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 3013679c..ecd18f44 100644 --- a/PluralKit.Bot/Commands/System.cs +++ b/PluralKit.Bot/Commands/System.cs @@ -1,42 +1,40 @@ -using System.Threading.Tasks; - using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class System { - public class System + private readonly IDatabase _db; + private readonly EmbedService _embeds; + private readonly ModelRepository _repo; + + public System(EmbedService embeds, IDatabase db, ModelRepository repo) { - private readonly EmbedService _embeds; - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _embeds = embeds; + _db = db; + _repo = 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; - 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))); + } - await ctx.Reply(embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system))); - } + public async Task New(Context ctx) + { + ctx.CheckNoSystem(); - 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 systemName = ctx.RemainderOrNull(); - if (systemName != null && systemName.Length > Limits.MaxSystemNameLength) - throw Errors.StringTooLongError("System name", systemName.Length, Limits.MaxSystemNameLength); + var system = await _repo.CreateSystem(systemName); + await _repo.AddAccount(system.Id, ctx.Author.Id); - var system = await _repo.CreateSystem(systemName); - await _repo.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: "); - } + // 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 bbf05c89..ef230782 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -1,10 +1,7 @@ -using System; -using System.Linq; -using System.Net.Http; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Myriad.Builders; +using Myriad.Types; using NodaTime; using NodaTime.Text; @@ -12,641 +9,698 @@ using NodaTime.TimeZones; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class SystemEdit { - public class SystemEdit + private readonly HttpClient _client; + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public SystemEdit(IDatabase db, ModelRepository repo, HttpClient client) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly HttpClient _client; + _db = db; + _repo = repo; + _client = client; + } - public SystemEdit(IDatabase db, ModelRepository repo, HttpClient client) + public async Task Name(Context ctx) + { + var noNameSetMessage = "Your system does not have a name set. Type `pk;system name ` to set one."; + + ctx.CheckSystem(); + + if (ctx.MatchRaw()) { - _db = db; - _repo = repo; - _client = client; + if (ctx.System.Name != null) + await ctx.Reply($"```\n{ctx.System.Name}\n```"); + else + await ctx.Reply(noNameSetMessage); + return; } - public async Task Name(Context ctx) + if (!ctx.HasNext(false)) { - var noNameSetMessage = "Your system does not have a name set. Type `pk;system name ` to set one."; - - ctx.CheckSystem(); - - if (ctx.MatchRaw()) - { - if (ctx.System.Name != null) - await ctx.Reply($"```\n{ctx.System.Name}\n```"); - else - await ctx.Reply(noNameSetMessage); - return; - } - if (!ctx.HasNext(false)) - { - 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(noNameSetMessage); - return; - } - - if (await ctx.MatchClear("your system's name")) - { - await _repo.UpdateSystem(ctx.System.Id, new() { Name = null }); - - await ctx.Reply($"{Emojis.Success} System name cleared."); - } + 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 - { - var newSystemName = ctx.RemainderOrNull(skipFlags: false).NormalizeLineEndSpacing(); - - if (newSystemName.Length > Limits.MaxSystemNameLength) - throw Errors.StringTooLongError("System name", newSystemName.Length, Limits.MaxSystemNameLength); - - await _repo.UpdateSystem(ctx.System.Id, new() { Name = newSystemName }); - - await ctx.Reply($"{Emojis.Success} System name changed."); - } + await ctx.Reply(noNameSetMessage); + return; } - public async Task Description(Context ctx) + if (await ctx.MatchClear("your system's name")) { - var noDescriptionSetMessage = "Your system does not have a description set. To set one, type `pk;s description `."; + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { Name = null }); - ctx.CheckSystem(); + await ctx.Reply($"{Emojis.Success} System name cleared."); + } + else + { + var newSystemName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - if (ctx.MatchRaw()) - { - if (ctx.System.Description == null) - await ctx.Reply(noDescriptionSetMessage); - else - await ctx.Reply($"```\n{ctx.System.Description}\n```"); - return; - } - if (!ctx.HasNext(false)) - { - if (ctx.System.Description == null) - await ctx.Reply(noDescriptionSetMessage); - else - await ctx.Reply(embed: new EmbedBuilder() - .Title("System description") - .Description(ctx.System.Description) - .Footer(new("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()); - return; - } + if (newSystemName.Length > Limits.MaxSystemNameLength) + throw Errors.StringTooLongError("System name", newSystemName.Length, Limits.MaxSystemNameLength); - if (await ctx.MatchClear("your system's description")) - { - await _repo.UpdateSystem(ctx.System.Id, new() { Description = null }); + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { Name = newSystemName }); - await ctx.Reply($"{Emojis.Success} System description cleared."); - } + await ctx.Reply($"{Emojis.Success} System name changed."); + } + } + + public async Task Description(Context ctx) + { + var noDescriptionSetMessage = + "Your system does not have a description set. To set one, type `pk;s description `."; + + ctx.CheckSystem(); + + if (ctx.MatchRaw()) + { + if (ctx.System.Description == null) + await ctx.Reply(noDescriptionSetMessage); else - { - var newDescription = ctx.RemainderOrNull(skipFlags: false).NormalizeLineEndSpacing(); - if (newDescription.Length > Limits.MaxDescriptionLength) - throw Errors.StringTooLongError("Description", newDescription.Length, Limits.MaxDescriptionLength); - - await _repo.UpdateSystem(ctx.System.Id, new() { Description = newDescription }); - - await ctx.Reply($"{Emojis.Success} System description changed."); - } + await ctx.Reply($"```\n{ctx.System.Description}\n```"); + return; } - public async Task Color(Context ctx) + if (!ctx.HasNext(false)) { - ctx.CheckSystem(); - - if (await ctx.MatchClear()) - { - await _repo.UpdateSystem(ctx.System.Id, new() { Color = Partial.Null() }); - - await ctx.Reply($"{Emojis.Success} System color cleared."); - } - else if (!ctx.HasNext()) - { - if (ctx.System.Color == null) - await ctx.Reply( - $"Your system does not have a color set. To set one, type `pk;system color `."); - else - await ctx.Reply(embed: new EmbedBuilder() - .Title("System color") - .Color(ctx.System.Color.ToDiscordColor()) - .Thumbnail(new($"https://fakeimg.pl/256x256/{ctx.System.Color}/?text=%20")) - .Description($"Your system's color is **#{ctx.System.Color}**. To clear it, type `pk;s color -clear`.") - .Build()); - } + if (ctx.System.Description == null) + await ctx.Reply(noDescriptionSetMessage); 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 _repo.UpdateSystem(ctx.System.Id, new() { Color = Partial.Present(color.ToLowerInvariant()) }); - await ctx.Reply(embed: new EmbedBuilder() - .Title($"{Emojis.Success} System color changed.") - .Color(color.ToDiscordColor()) - .Thumbnail(new($"https://fakeimg.pl/256x256/{color}/?text=%20")) + .Title("System description") + .Description(ctx.System.Description) + .Footer(new Embed.EmbedFooter( + "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()); - } + return; } - public async Task Tag(Context ctx) + if (await ctx.MatchClear("your system's description")) { - var noTagSetMessage = "You currently have no system tag. To set one, type `pk;s tag `."; + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { Description = null }); - ctx.CheckSystem(); + await ctx.Reply($"{Emojis.Success} System description cleared."); + } + else + { + var newDescription = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + if (newDescription.Length > Limits.MaxDescriptionLength) + throw Errors.StringTooLongError("Description", newDescription.Length, Limits.MaxDescriptionLength); - if (ctx.MatchRaw()) - { - if (ctx.System.Tag == null) - await ctx.Reply(noTagSetMessage); - else - await ctx.Reply($"```\n{ctx.System.Tag}\n```"); - return; - } - if (!ctx.HasNext(false)) - { - if (ctx.System.Tag == null) - await ctx.Reply(noTagSetMessage); - 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`."); - return; - } + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { Description = newDescription }); - if (await ctx.MatchClear("your system's tag")) - { - await _repo.UpdateSystem(ctx.System.Id, new() { Tag = null }); + await ctx.Reply($"{Emojis.Success} System description changed."); + } + } - await ctx.Reply($"{Emojis.Success} System tag cleared."); - } + public async Task Color(Context ctx) + { + ctx.CheckSystem(); + + if (await ctx.MatchClear()) + { + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { Color = Partial.Null() }); + + await ctx.Reply($"{Emojis.Success} System color cleared."); + } + else if (!ctx.HasNext()) + { + if (ctx.System.Color == null) + await ctx.Reply( + "Your system does not have a color set. To set one, type `pk;system color `."); else - { - var newTag = ctx.RemainderOrNull(skipFlags: false).NormalizeLineEndSpacing(); - if (newTag != null) - if (newTag.Length > Limits.MaxSystemTagLength) - throw Errors.StringTooLongError("System tag", newTag.Length, Limits.MaxSystemTagLength); + await ctx.Reply(embed: new EmbedBuilder() + .Title("System color") + .Color(ctx.System.Color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{ctx.System.Color}/?text=%20")) + .Description( + $"Your system's color is **#{ctx.System.Color}**. To clear it, type `pk;s color -clear`.") + .Build()); + } + else + { + var color = ctx.RemainderOrNull(); - await _repo.UpdateSystem(ctx.System.Id, new() { Tag = newTag }); + if (color.StartsWith("#")) color = color.Substring(1); + if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); - await ctx.Reply($"{Emojis.Success} System tag changed. Member names will now end with {newTag.AsCode()} when proxied."); - } + await _repo.UpdateSystem(ctx.System.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) + { + var noTagSetMessage = "You currently have no system tag. To set one, type `pk;s tag `."; + + ctx.CheckSystem(); + + if (ctx.MatchRaw()) + { + if (ctx.System.Tag == null) + await ctx.Reply(noTagSetMessage); + else + await ctx.Reply($"```\n{ctx.System.Tag}\n```"); + return; } - public async Task ServerTag(Context ctx) + if (!ctx.HasNext(false)) { - ctx.CheckSystem().CheckGuildContext(); + if (ctx.System.Tag == null) + await ctx.Reply(noTagSetMessage); + 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`."); + return; + } - 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`."; + if (await ctx.MatchClear("your system's tag")) + { + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { Tag = null }); - var settings = await _repo.GetSystemGuild(ctx.Guild.Id, ctx.System.Id); + 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); - async Task Show(bool raw = false) + await _repo.UpdateSystem(ctx.System.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) + { + ctx.CheckSystem().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 _repo.GetSystemGuild(ctx.Guild.Id, ctx.System.Id); + + async Task Show(bool raw = false) + { + if (settings.Tag != null) { - if (settings.Tag != null) + if (raw) { - 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); + await ctx.Reply($"```{settings.Tag}```"); return; } - else if (!settings.TagEnabled) - await ctx.Reply($"Your global system tag is {ctx.System.Tag}, but it is **disabled** in this server. To re-enable it, type `pk;s servertag -enable`"); + 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 - 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`."); - } + msg += + ". To change it, type `pk;s servertag `. To clear it, type `pk;s servertag -clear`."; - async Task Set() - { - var newTag = ctx.RemainderOrNull(skipFlags: false); - if (newTag != null && newTag.Length > Limits.MaxSystemTagLength) - throw Errors.StringTooLongError("System server tag", newTag.Length, Limits.MaxSystemTagLength); - - await _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { 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 _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { 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 _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { 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 == true) - { - 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(raw: true); - else if (!ctx.HasNext(skipFlags: false)) - await Show(); - else - await Set(); - } - - public async Task Avatar(Context ctx, PKSystem target = null) - { - if (target == null) - ctx.CheckSystem(); - - async Task ClearIcon() - { - await _repo.UpdateSystem(ctx.System.Id, new() { AvatarUrl = null }); - await ctx.Reply($"{Emojis.Success} System icon cleared."); - } - - async Task SetIcon(ParsedImage img) - { - await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); - - await _repo.UpdateSystem(ctx.System.Id, new() { 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 EmbedBuilder().Image(new(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowIcon() - { - var system = target ?? ctx.System; - if ((system.AvatarUrl?.Trim() ?? "").Length > 0) - { - var eb = new EmbedBuilder() - .Title("System icon") - .Image(new(system.AvatarUrl.TryGetCleanCdnUrl())); - if (system.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(); + await ctx.Reply(msg); return; } - if (await ctx.MatchClear("your system's icon")) - await ClearIcon(); - else if (await ctx.MatchImage() is { } img) - await SetIcon(img); + if (!settings.TagEnabled) + await ctx.Reply( + $"Your global system tag is {ctx.System.Tag}, but it is **disabled** in this server. To re-enable it, type `pk;s servertag -enable`"); else - await ShowIcon(); + 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`."); } - public async Task BannerImage(Context ctx) + async Task Set() { - ctx.CheckSystem(); + var newTag = ctx.RemainderOrNull(false); + if (newTag != null && newTag.Length > Limits.MaxSystemTagLength) + throw Errors.StringTooLongError("System server tag", newTag.Length, Limits.MaxSystemTagLength); - async Task ClearImage() - { - await _repo.UpdateSystem(ctx.System.Id, new() { BannerImage = null }); - await ctx.Reply($"{Emojis.Success} System banner image cleared."); - } + await _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new SystemGuildPatch { Tag = newTag }); - async Task SetImage(ParsedImage img) - { - await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, isFullSizeImage: true); + 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}'."); - await _repo.UpdateSystem(ctx.System.Id, new() { 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, embed: new EmbedBuilder().Image(new(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowImage() - { - if ((ctx.System.BannerImage?.Trim() ?? "").Length > 0) - { - var eb = new EmbedBuilder() - .Title("System banner image") - .Image(new(ctx.System.BannerImage)) - .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. Set one by attaching an image to this command, or by passing an image URL or @mention."); - } - - if (await ctx.MatchClear("your system's banner image")) - await ClearImage(); - else if (await ctx.MatchImage() is { } img) - await SetImage(img); - else - await ShowImage(); + if (!ctx.MessageContext.TagEnabled) + await ctx.Reply(setDisabledWarning); } - public async Task Delete(Context ctx) + async Task Clear() { - ctx.CheckSystem(); + await _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new SystemGuildPatch { Tag = null }); - 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*."); + await ctx.Reply( + $"{Emojis.Success} System server tag cleared. Member names will now end with the global system tag, if there is one set."); - await _repo.DeleteSystem(ctx.System.Id); - - await ctx.Reply($"{Emojis.Success} System deleted."); + if (!ctx.MessageContext.TagEnabled) + await ctx.Reply(setDisabledWarning); } - public async Task SystemProxy(Context ctx) + async Task EnableDisable(bool newValue) { - ctx.CheckSystem(); + await _repo.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, + new SystemGuildPatch { TagEnabled = newValue }); - var guild = await ctx.MatchGuild() ?? ctx.Guild ?? - throw new PKError("You must run this command in a server or pass a server ID."); + await ctx.Reply(PrintEnableDisableResult(newValue, newValue != ctx.MessageContext.TagEnabled)); + } - var gs = await _repo.GetSystemGuild(guild.Id, ctx.System.Id); + string PrintEnableDisableResult(bool newValue, bool changedValue) + { + var opStr = newValue ? "enabled" : "disabled"; + var str = ""; - string serverText; - if (guild.Id == ctx.Guild?.Id) - serverText = $"this server ({guild.Name.EscapeMarkdown()})"; + if (!changedValue) + str = $"{Emojis.Note} The system tag is already {opStr} in this server."; else - serverText = $"the server {guild.Name.EscapeMarkdown()}"; - - 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 {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; - } - - await _repo.UpdateSystemGuild(ctx.System.Id, guild.Id, new() { ProxyEnabled = newValue }); + str = $"{Emojis.Success} System tag {opStr} in this server."; 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 SystemTimezone(Context ctx) - { - if (ctx.System == null) throw Errors.NoSystemError; - - if (await ctx.MatchClear()) { - await _repo.UpdateSystem(ctx.System.Id, new() { UiTz = "UTC" }); - - await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC)."); - return; + 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."; + } } - 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, "Change Timezone")) throw Errors.TimezoneChangeCancelled; - - await _repo.UpdateSystem(ctx.System.Id, new() { UiTz = zone.Id }); - - await ctx.Reply($"System time zone changed to **{zone.Id}**."); + return str; } - public async Task SystemPrivacy(Context ctx) - { + 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 Avatar(Context ctx, PKSystem target = null) + { + if (target == null) ctx.CheckSystem(); - Task PrintEmbed() + async Task ClearIcon() + { + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { AvatarUrl = null }); + await ctx.Reply($"{Emojis.Success} System icon cleared."); + } + + async Task SetIcon(ParsedImage img) + { + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); + + await _repo.UpdateSystem(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, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + async Task ShowIcon() + { + var system = target ?? ctx.System; + if ((system.AvatarUrl?.Trim() ?? "").Length > 0) { var eb = new EmbedBuilder() - .Title("Current privacy settings for your system") - .Field(new("Description", ctx.System.DescriptionPrivacy.Explanation())) - .Field(new("Member list", ctx.System.MemberListPrivacy.Explanation())) - .Field(new("Group list", ctx.System.GroupListPrivacy.Explanation())) - .Field(new("Current fronter(s)", ctx.System.FrontPrivacy.Explanation())) - .Field(new("Front/switch history", ctx.System.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 _repo.UpdateSystem(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 _repo.UpdateSystem(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`."); } + .Title("System icon") + .Image(new Embed.EmbedImage(system.AvatarUrl.TryGetCleanCdnUrl())); + if (system.Id == ctx.System?.Id) + eb.Description("To clear, use `pk;system icon clear`."); + await ctx.Reply(embed: eb.Build()); } else { - if (ctx.Match("on", "enable")) - { - await _repo.UpdateSystem(ctx.System.Id, new() { PingsEnabled = true }); - - await ctx.Reply("Reaction pings have now been enabled."); - } - if (ctx.Match("off", "disable")) - { - await _repo.UpdateSystem(ctx.System.Id, new() { PingsEnabled = false }); - - await ctx.Reply("Reaction pings have now been disabled."); - } + 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."); } } - public async Task FindTimeZone(Context ctx, string zoneStr) + if (target != null && target?.Id != ctx.System?.Id) { - // First, if we're given a flag emoji, we extract the flag emoji code from it. - zoneStr = Core.StringUtils.ExtractCountryFlag(zoneStr) ?? zoneStr; + await ShowIcon(); + return; + } - // 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))); + if (await ctx.MatchClear("your system's icon")) + await ClearIcon(); + else if (await ctx.MatchImage() is { } img) + await SetIcon(img); + else + await ShowIcon(); + } - // Then, we find all (unique) time zone IDs that match. - var matchingZones = matchingLocations.Select(l => DateTimeZoneProviders.Tzdb.GetZoneOrNull(l.ZoneId)) - .Distinct().ToList(); + public async Task BannerImage(Context ctx) + { + ctx.CheckSystem(); - // If the set of matching zones is empty (ie. we didn't find anything), we try a few other things. - if (matchingZones.Count == 0) + async Task ClearImage() + { + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { BannerImage = null }); + await ctx.Reply($"{Emojis.Success} System banner image cleared."); + } + + async Task SetImage(ParsedImage img) + { + await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true); + + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { BannerImage = img.Url }); + + var msg = img.Source switch { - // 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; + 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() + }; - // 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", ""); + // 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)); + } - var res = OffsetPattern.CreateWithInvariantCulture("+H").Parse(inputWithoutUtc); - if (!res.Success) res = OffsetPattern.CreateWithInvariantCulture("+H:mm").Parse(inputWithoutUtc); + async Task ShowImage() + { + if ((ctx.System.BannerImage?.Trim() ?? "").Length > 0) + { + var eb = new EmbedBuilder() + .Title("System banner image") + .Image(new Embed.EmbedImage(ctx.System.BannerImage)) + .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. Set one by attaching an image to this command, or by passing an image URL or @mention."); + } + } - // If *this* didn't parse correctly, fuck it, bail. - if (!res.Success) return null; - var offset = res.Value; + if (await ctx.MatchClear("your system's banner image")) + await ClearImage(); + else if (await ctx.MatchImage() is { } img) + await SetImage(img); + else + await ShowImage(); + } - // 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(); + 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*."); + + await _repo.DeleteSystem(ctx.System.Id); + + await ctx.Reply($"{Emojis.Success} System deleted."); + } + + 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 _repo.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()}"; + + bool newValue; + // todo: MatchToggle + 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 {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; + } + + await _repo.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 SystemTimezone(Context ctx) + { + if (ctx.System == null) throw Errors.NoSystemError; + + if (await ctx.MatchClear()) + { + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { 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.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, "Change Timezone")) throw Errors.TimezoneChangeCancelled; + + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { UiTz = zone.Id }); + + await ctx.Reply($"System time zone changed to **{zone.Id}**."); + } + + public async Task SystemPrivacy(Context ctx) + { + ctx.CheckSystem(); + + Task PrintEmbed() + { + var eb = new EmbedBuilder() + .Title("Current privacy settings for your system") + .Field(new Embed.Field("Description", ctx.System.DescriptionPrivacy.Explanation())) + .Field(new Embed.Field("Member list", ctx.System.MemberListPrivacy.Explanation())) + .Field(new Embed.Field("Group list", ctx.System.GroupListPrivacy.Explanation())) + .Field(new Embed.Field("Current fronter(s)", ctx.System.FrontPrivacy.Explanation())) + .Field(new Embed.Field("Front/switch history", ctx.System.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 _repo.UpdateSystem(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 _repo.UpdateSystem(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")) + { + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { PingsEnabled = true }); + + await ctx.Reply("Reaction pings have now been enabled."); } - // If we have a list of viable time zones, we ask the user which is correct. + if (ctx.Match("off", "disable")) + { + await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { PingsEnabled = false }); - // 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("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 = 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}**"; + }); + } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index d3d777ad..95bb0959 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -1,147 +1,149 @@ -using System; -using System.Linq; using System.Text; -using System.Threading.Tasks; using NodaTime; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class SystemFront { - public class SystemFront + private readonly IDatabase _db; + private readonly EmbedService _embeds; + private readonly ModelRepository _repo; + + public SystemFront(EmbedService embeds, IDatabase db, ModelRepository repo) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly EmbedService _embeds; + _embeds = embeds; + _db = db; + _repo = repo; + } - public SystemFront(EmbedService embeds, IDatabase db, ModelRepository repo) - { - _embeds = embeds; - _db = db; - _repo = repo; - } + public async Task SystemFronter(Context ctx, PKSystem system) + { + if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system, system.FrontPrivacy); - struct FrontHistoryEntry - { - public readonly Instant? LastTime; - public readonly PKSwitch ThisSwitch; + var sw = await _repo.GetLatestSwitch(system.Id); + if (sw == null) throw Errors.NoRegisteredSwitches; - public FrontHistoryEntry(Instant? lastTime, PKSwitch thisSwitch) + 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 + // todo: this comment was here, but we're not getting a connection here anymore + // hopefully nothing breaks? + + var totalSwitches = await _repo.GetSwitchCount(system.Id); + if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; + + var sws = _repo.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); - - var sw = await _repo.GetLatestSwitch(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 - // todo: this comment was here, but we're not getting a connection here anymore - // hopefully nothing breaks? - - var totalSwitches = await _repo.GetSwitchCount(system.Id); - if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; - - var sws = _repo.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) => + var sb = new StringBuilder(); + foreach (var entry in switches) { - var sb = new StringBuilder(); - foreach (var entry in switches) + var lastSw = entry.LastTime; + + var sw = entry.ThisSwitch; + + // Fetch member list and format + + 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) { - var lastSw = entry.LastTime; - - var sw = entry.ThisSwitch; - - // Fetch member list and format - - 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"; - } - - if (sb.Length + stringToAdd.Length >= 4096) - break; - sb.Append(stringToAdd); + // 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"; } - builder.Description(sb.ToString()); + 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 SystemFrontPercent(Context ctx, PKSystem system) + { + if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy); + + var totalSwitches = await _repo.GetSwitchCount(system.Id); + if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; + + 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, system.Zone); + if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); + if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; + + var title = new StringBuilder("Frontpercent of "); + if (system.Name != null) + title.Append($"{system.Name} (`{system.Hid}`)"); + else + title.Append($"`{system.Hid}`"); + + var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only"); + var showFlat = ctx.MatchFlag("flat"); + var frontpercent = await _db.Execute(c => + _repo.GetFrontBreakdown(c, system.Id, null, rangeStart.Value.ToInstant(), now)); + await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system, null, system.Zone, + ctx.LookupContextFor(system), title.ToString(), ignoreNoFronters, showFlat)); + } + + 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); - - var totalSwitches = await _repo.GetSwitchCount(system.Id); - if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; - - string 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, system.Zone); - if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); - if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; - - var title = new StringBuilder($"Frontpercent of "); - if (system.Name != null) - title.Append($"{system.Name} (`{system.Hid}`)"); - else - title.Append($"`{system.Hid}`"); - - var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only"); - var showFlat = ctx.MatchFlag("flat"); - var frontpercent = await _db.Execute(c => _repo.GetFrontBreakdown(c, system.Id, null, rangeStart.Value.ToInstant(), now)); - await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system, null, system.Zone, ctx.LookupContextFor(system), title.ToString(), ignoreNoFronters, showFlat)); + 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 7f9cbfa4..9ebfe567 100644 --- a/PluralKit.Bot/Commands/SystemLink.cs +++ b/PluralKit.Bot/Commands/SystemLink.cs @@ -1,60 +1,56 @@ -using System.Linq; -using System.Threading.Tasks; - using Myriad.Extensions; -using Myriad.Rest.Types; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class SystemLink { - public class SystemLink + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public SystemLink(IDatabase db, ModelRepository repo) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; + _db = db; + _repo = repo; + } - public SystemLink(IDatabase db, ModelRepository repo) - { - _db = db; - _repo = repo; - } + public async Task LinkSystem(Context ctx) + { + ctx.CheckSystem(); - 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 _repo.GetSystemAccounts(ctx.System.Id); + if (accountIds.Contains(account.Id)) + throw Errors.AccountAlreadyLinked; - 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(ctx.System.Id); - if (accountIds.Contains(account.Id)) - throw Errors.AccountAlreadyLinked; + var existingAccount = await _repo.GetSystemByAccount(account.Id); + if (existingAccount != null) + throw Errors.AccountInOtherSystem(existingAccount); - var existingAccount = await _repo.GetSystemByAccount(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 _repo.AddAccount(ctx.System.Id, account.Id); + await ctx.Reply($"{Emojis.Success} Account linked to system."); + } - var msg = $"{account.Mention()}, please confirm the link."; - if (!await ctx.PromptYesNo(msg, "Confirm", user: account, matchFlag: false)) throw Errors.MemberLinkCancelled; - await _repo.AddAccount(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(); + 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.MatchUserRaw(out id)) - throw new PKSyntaxError("You must pass an account to link with (either ID or @mention)."); + var accountIds = (await _repo.GetSystemAccounts(ctx.System.Id)).ToList(); + if (!accountIds.Contains(id)) throw Errors.AccountNotLinked; + if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount; - var accountIds = (await _repo.GetSystemAccounts(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, "Unlink")) 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(ctx.System.Id, id); - await ctx.Reply($"{Emojis.Success} Account unlinked."); - } + await _repo.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 349e1667..95e3ee24 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -1,41 +1,46 @@ using System.Text; -using System.Threading.Tasks; using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class SystemList { - public class SystemList + private readonly IDatabase _db; + + public SystemList(IDatabase db) { - private readonly IDatabase _db; + _db = db; + } - public SystemList(IDatabase db) - { - _db = db; - } + public async Task MemberList(Context ctx, PKSystem target) + { + if (target == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(target, target.MemberListPrivacy); - public async Task MemberList(Context ctx, PKSystem target) - { - if (target == null) throw Errors.NoSystemError; - ctx.CheckSystemPrivacy(target, target.MemberListPrivacy); + var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(target)); + await ctx.RenderMemberList( + ctx.LookupContextFor(target), + _db, + 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), target.Color, opts); - } + private string GetEmbedTitle(PKSystem target, MemberListOptions 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 (target.Name != null) - title.Append($"{target.Name} (`{target.Hid}`)"); - else - title.Append($"`{target.Hid}`"); + if (opts.Search != null) + title.Append($" matching **{opts.Search}**"); - if (opts.Search != null) - title.Append($" matching **{opts.Search}**"); - - return title.ToString(); - } + return title.ToString(); } } \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index d9279363..b15b30d0 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -1,119 +1,184 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; + using Humanizer; + using NodaTime; + using PluralKit.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +/// +/// An exception class representing user-facing errors caused when parsing and executing commands. +/// +public class PKError: Exception { - /// - /// 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 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 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 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 SwitchMoveBeforeSecondLast(ZonedDateTime time) => new( + $"Can't move switch to before last switch time ({time.FormatZoned()}), as it would cause conflicts."); - 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 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 StringTooLongError(string name, int length, int maxLength) => new PKError($"{name} too long ({length}/{maxLength} characters)."); + 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 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 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 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 MessageNotFound(ulong id) => + new($"Message with ID '{id}' not found. Are you sure it's a message proxied by PluralKit?"); - 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 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 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 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 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 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 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 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 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 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 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 SwitchEditCancelled => new PKError("Switch edit 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 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 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 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 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 LegacyAlreadyHasProxyTag(ProxyTag requested, PKMember member) => new( + $"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 GuildNotFound(ulong guildId) => new PKError($"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 EmptyProxyTags(PKMember member) => 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()} proxy clear`."); - 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 GenericCancelled() => new("Operation cancelled."); - 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 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 GenericCancelled() => new PKError("Operation cancelled."); - - public static PKError AttachmentTooLarge(int mb) => new PKError($"PluralKit cannot proxy attachments over {mb} megabytes in this server (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 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 6f5f5549..8cdac7c2 100644 --- a/PluralKit.Bot/Handlers/IEventHandler.cs +++ b/PluralKit.Bot/Handlers/IEventHandler.cs @@ -1,13 +1,10 @@ -using System.Threading.Tasks; - using Myriad.Gateway; -namespace PluralKit.Bot -{ - public interface IEventHandler where T : IGatewayEvent - { - Task Handle(Shard shard, T evt); +namespace PluralKit.Bot; - ulong? ErrorChannelFor(T evt) => null; - } +public interface IEventHandler where T : IGatewayEvent +{ + Task Handle(Shard shard, T evt); + + ulong? ErrorChannelFor(T evt) => null; } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/InteractionCreated.cs b/PluralKit.Bot/Handlers/InteractionCreated.cs index 24fa37c1..42eac9f2 100644 --- a/PluralKit.Bot/Handlers/InteractionCreated.cs +++ b/PluralKit.Bot/Handlers/InteractionCreated.cs @@ -1,33 +1,30 @@ -using System.Threading.Tasks; - using Autofac; using Myriad.Gateway; using Myriad.Types; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class InteractionCreated: IEventHandler { - public class InteractionCreated: IEventHandler + private readonly InteractionDispatchService _interactionDispatch; + private readonly ILifetimeScope _services; + + public InteractionCreated(InteractionDispatchService interactionDispatch, ILifetimeScope services) { - private readonly InteractionDispatchService _interactionDispatch; - private readonly ILifetimeScope _services; + _interactionDispatch = interactionDispatch; + _services = services; + } - public InteractionCreated(InteractionDispatchService interactionDispatch, ILifetimeScope services) + public async Task Handle(Shard shard, InteractionCreateEvent evt) + { + if (evt.Type == Interaction.InteractionType.MessageComponent) { - _interactionDispatch = interactionDispatch; - _services = services; - } - - public async Task Handle(Shard shard, InteractionCreateEvent evt) - { - if (evt.Type == Interaction.InteractionType.MessageComponent) + var customId = evt.Data?.CustomId; + if (customId != null) { - var customId = evt.Data?.CustomId; - if (customId != null) - { - var ctx = new InteractionContext(evt, _services); - await _interactionDispatch.Dispatch(customId, ctx); - } + var ctx = new InteractionContext(evt, _services); + await _interactionDispatch.Dispatch(customId, ctx); } } } diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index 04a52de2..76533a38 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading.Tasks; - using App.Metrics; using Autofac; @@ -14,171 +11,174 @@ 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; + + 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) { - private readonly Bot _bot; - private readonly Cluster _cluster; - private readonly CommandTree _tree; - private readonly IDiscordCache _cache; - 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; - private readonly DiscordApiClient _rest; + _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; + } - 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) + // for now, only return error messages for explicit commands + public ulong? ErrorChannelFor(MessageCreateEvent evt) + { + // todo: fix @mention prefix + // it only breaks error reporting so I'm not *too* worried about it, but should be fixed eventually + if (!HasCommandPrefix(evt.Content, default, out var cmdStart) || cmdStart == evt.Content.Length) + return null; + + return 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(Shard shard, MessageCreateEvent evt) + { + if (evt.Author.Id == shard.User?.Id) return; + if (evt.Type != Message.MessageType.Default && evt.Type != Message.MessageType.Reply) return; + if (IsDuplicateMessage(evt)) return; + + 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(shard, evt, guild, channel, ctx)) + return; + await TryHandleProxy(shard, 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(Shard shard, MessageCreateEvent evt, Guild? guild, + Channel channel, MessageContext ctx) + { + var content = evt.Content; + if (content == null) return false; + + // Check for command prefix + if (!HasCommandPrefix(content, shard.User?.Id ?? default, out var cmdStart) || cmdStart == content.Length) + return false; + + // 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 { - _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; + var system = ctx.SystemId != null ? await _repo.GetSystem(ctx.SystemId.Value) : null; + await _tree.ExecuteCommand(new Context(_services, shard, guild, channel, evt, 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. } - // for now, only return error messages for explicit commands - public ulong? ErrorChannelFor(MessageCreateEvent evt) + return true; + } + + 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) { - // todo: fix @mention prefix - // it only breaks error reporting so I'm not *too* worried about it, but should be fixed eventually - if (!HasCommandPrefix(evt.Content, default, out var cmdStart) || cmdStart == evt.Content.Length) - return null; + if (!message.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) continue; - return 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(Shard shard, MessageCreateEvent evt) - { - if (evt.Author.Id == shard.User?.Id) return; - if (evt.Type != Message.MessageType.Default && evt.Type != Message.MessageType.Reply) return; - if (IsDuplicateMessage(evt)) return; - - 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(shard, evt, guild, channel, ctx)) - return; - await TryHandleProxy(shard, 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); + argPos = prefix.Length; return true; } - private async ValueTask TryHandleCommand(Shard shard, MessageCreateEvent evt, Guild? guild, Channel channel, MessageContext ctx) + // 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; + } + + private async ValueTask TryHandleProxy(Shard shard, MessageCreateEvent evt, Guild guild, Channel channel, + MessageContext ctx) + { + var botPermissions = await _cache.PermissionsIn(channel.Id); + + try { - var content = evt.Content; - if (content == null) return false; - - // Check for command prefix - if (!HasCommandPrefix(content, shard.User?.Id ?? default, out var cmdStart) || cmdStart == content.Length) - return false; - - // 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 _repo.GetSystem(ctx.SystemId.Value) : null; - await _tree.ExecuteCommand(new Context(_services, shard, guild, channel, evt, 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. - } - - return true; + return await _proxy.HandleIncomingMessage(shard, evt, ctx, guild, channel, ctx.AllowAutoproxy, + botPermissions); } - private bool HasCommandPrefix(string message, ulong currentUserId, out int argPos) + // Catch any failed proxy checks so they get ignored in the global error handler + catch (ProxyService.ProxyChecksFailedException) { } + + catch (PKError e) { - // 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 == currentUserId; - - return false; + // 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}" }); } - private async ValueTask TryHandleProxy(Shard shard, MessageCreateEvent evt, Guild guild, Channel channel, MessageContext ctx) - { - var botPermissions = await _cache.PermissionsIn(channel.Id); - - try - { - return await _proxy.HandleIncomingMessage(shard, evt, ctx, guild, channel, allowAutoproxy: ctx.AllowAutoproxy, botPermissions); - } - - // Catch any failed proxy checks so they get ignored in the global error handler - catch (ProxyService.ProxyChecksFailedException) { } - - 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; - } + return false; } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageDeleted.cs b/PluralKit.Bot/Handlers/MessageDeleted.cs index cb78fefd..5e883a51 100644 --- a/PluralKit.Bot/Handlers/MessageDeleted.cs +++ b/PluralKit.Bot/Handlers/MessageDeleted.cs @@ -1,67 +1,63 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - 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; - private readonly LastMessageCacheService _lastMessage; - - public MessageDeleted(ILogger logger, IDatabase db, ModelRepository repo, LastMessageCacheService lastMessage) - { - _db = db; - _repo = repo; - _lastMessage = lastMessage; - _logger = logger.ForContext(); - } - - public Task Handle(Shard shard, 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(Shard shard, 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; - } + _db = db; + _repo = repo; + _lastMessage = lastMessage; + _logger = logger.ForContext(); } + + public Task Handle(Shard shard, 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(Shard shard, 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 8ee024f5..480f924e 100644 --- a/PluralKit.Bot/Handlers/MessageEdited.cs +++ b/PluralKit.Bot/Handlers/MessageEdited.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading.Tasks; - using App.Metrics; using Myriad.Cache; @@ -13,114 +10,117 @@ 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 Cluster _client; - private readonly IDiscordCache _cache; - private readonly Bot _bot; - private readonly DiscordApiClient _rest; - private readonly ILogger _logger; + _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, Cluster client, IDiscordCache cache, Bot bot, DiscordApiClient rest, ILogger logger) + public async Task Handle(Shard shard, 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; - _cache = cache; - _bot = bot; - _rest = rest; - _logger = logger.ForContext(); + await _proxy.HandleIncomingMessage(shard, 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(Shard shard, 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 - { - await _proxy.HandleIncomingMessage(shard, 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; - } - - return await _rest.GetMessage(channelId, referencedMessageId.Value); - } + 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 4fffe4d6..8241a7a0 100644 --- a/PluralKit.Bot/Handlers/ReactionAdded.cs +++ b/PluralKit.Bot/Handlers/ReactionAdded.cs @@ -1,6 +1,3 @@ -using System.Threading.Tasks; - -using Myriad.Builders; using Myriad.Cache; using Myriad.Extensions; using Myriad.Gateway; @@ -14,234 +11,242 @@ using PluralKit.Core; 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; + + public ReactionAdded(ILogger logger, IDatabase db, ModelRepository repo, + CommandMessageService commandMessageService, IDiscordCache cache, Bot bot, Cluster cluster, + DiscordApiClient rest, EmbedService embeds) { - private readonly IDatabase _db; - private readonly ModelRepository _repo; - private readonly CommandMessageService _commandMessageService; - private readonly ILogger _logger; - private readonly IDiscordCache _cache; - private readonly EmbedService _embeds; - private readonly Bot _bot; - private readonly Cluster _cluster; - private readonly DiscordApiClient _rest; + _db = db; + _repo = repo; + _commandMessageService = commandMessageService; + _cache = cache; + _bot = bot; + _cluster = cluster; + _rest = rest; + _embeds = embeds; + _logger = logger.ForContext(); + } - public ReactionAdded(ILogger logger, IDatabase db, ModelRepository repo, CommandMessageService commandMessageService, IDiscordCache cache, Bot bot, Cluster cluster, DiscordApiClient rest, EmbedService embeds) + public async Task Handle(Shard shard, MessageReactionAddEvent evt) + { + await TryHandleProxyMessageReactions(evt); + } + + private async ValueTask TryHandleProxyMessageReactions(MessageReactionAddEvent evt) + { + // Sometimes we get events from users that aren't in the user cache + // We just ignore all of those for now, should be quite rare... + if (!(await _cache.TryGetUser(evt.UserId) is User user)) + return; + + // ignore any reactions added by *us* + if (evt.UserId == await _cache.GetOwnUser()) + return; + + // Ignore reactions from bots (we can't DM them anyway) + if (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") { - _db = db; - _repo = repo; - _commandMessageService = commandMessageService; - _cache = cache; - _bot = bot; - _cluster = cluster; - _rest = rest; - _embeds = embeds; - _logger = logger.ForContext(); - } - - public async Task Handle(Shard shard, MessageReactionAddEvent evt) - { - await TryHandleProxyMessageReactions(evt); - } - - private async ValueTask TryHandleProxyMessageReactions(MessageReactionAddEvent evt) - { - // Sometimes we get events from users that aren't in the user cache - // We just ignore all of those for now, should be quite rare... - if (!(await _cache.TryGetUser(evt.UserId) is User user)) - return; - - // ignore any reactions added by *us* - if (evt.UserId == await _cache.GetOwnUser()) - return; - - // Ignore reactions from bots (we can't DM them anyway) - if (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") + // in DMs, allow deleting any PK message + if (channel.GuildId == null) { - // in DMs, allow deleting any PK message - if (channel.GuildId == null) + await HandleCommandDeleteReaction(evt, null); + return; + } + + var commandMsg = await _commandMessageService.GetCommandMessage(evt.MessageId); + if (commandMsg != null) + { + 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 HandleCommandDeleteReaction(evt, null); - return; + 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 + { + var msg = await _db.Execute(c => _repo.GetMessage(c, evt.MessageId)); + if (msg != null) + await HandleQueryReaction(evt, msg); + + break; } - var commandMsg = await _commandMessageService.GetCommandMessage(evt.MessageId); - if (commandMsg != null) + case "\U0001F514": // Bell + case "\U0001F6CE": // Bellhop bell + case "\U0001F3D3": // Ping pong paddle (lol) + case "\u23F0": // Alarm clock + case "\u2757": // Exclamation mark { - await HandleCommandDeleteReaction(evt, commandMsg); - return; + var msg = await _db.Execute(c => _repo.GetMessage(c, evt.MessageId)); + if (msg != null) + await HandlePingReaction(evt, msg); + break; } - } + } + } - // Proxied messages only exist in guild text channels, so skip checking if we're elsewhere - if (!DiscordUtils.IsValidGuildChannel(channel)) return; + private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEvent evt, FullMessage msg) + { + if (!(await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages)) + return; - switch (evt.Emoji.Name) - { - // Message deletion - case "\u274C": // Red X - { - var msg = await _db.Execute(c => _repo.GetMessage(c, evt.MessageId)); - if (msg != null) - await HandleProxyDeleteReaction(evt, msg); + var system = await _repo.GetSystemByAccount(evt.UserId); - break; - } - case "\u2753": // Red question mark - case "\u2754": // White question mark - { - var msg = await _db.Execute(c => _repo.GetMessage(c, evt.MessageId)); - if (msg != null) - await HandleQueryReaction(evt, msg); + // Can only delete your own message + if (msg.System.Id != system?.Id) return; - break; - } - - case "\U0001F514": // Bell - case "\U0001F6CE": // Bellhop bell - case "\U0001F3D3": // Ping pong paddle (lol) - case "\u23F0": // Alarm clock - case "\u2757": // Exclamation mark - { - var msg = await _db.Execute(c => _repo.GetMessage(c, evt.MessageId)); - if (msg != null) - await HandlePingReaction(evt, msg); - break; - } - } + try + { + await _rest.DeleteMessage(evt.ChannelId, evt.MessageId); + } + catch (NotFoundException) + { + // Message was deleted by something/someone else before we got to it } - private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEvent evt, FullMessage msg) + 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) + // todo: don't try to delete the user's messages + if (msg != null && msg.AuthorId != evt.UserId) + return; + + try { - 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) 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); + await _rest.DeleteMessage(evt.ChannelId, evt.MessageId); + } + catch (NotFoundException) + { + // Message was deleted by something/someone else before we got to it } - private async ValueTask HandleCommandDeleteReaction(MessageReactionAddEvent evt, CommandMessage? msg) + // 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 { - // Can only delete your own message - // (except in DMs, where msg will be null) - if (msg != null && msg.AuthorId != evt.UserId) - return; - - try + var dm = await _cache.GetOrCreateDmChannel(_rest, evt.UserId); + await _rest.CreateMessage(dm.Id, new MessageRequest { - await _rest.DeleteMessage(evt.ChannelId, evt.MessageId); - } - catch (NotFoundException) - { - // Message was deleted by something/someone else before we got to it - } + Embed = await _embeds.CreateMemberEmbed( + msg.System, + msg.Member, + guild, + LookupContext.ByNonOwner + ) + }); - // No need to delete database row here, it'll get deleted by the once-per-minute scheduled task. + await _rest.CreateMessage( + dm.Id, + new MessageRequest { Embed = await _embeds.CreateMessageInfoEmbed(msg, true) } + ); } + catch (ForbiddenException) { } // No permissions to DM, can't check for this :( - private async ValueTask HandleQueryReaction(MessageReactionAddEvent evt, FullMessage msg) - { - var guild = await _cache.GetGuild(evt.GuildId!.Value); + await TryRemoveOriginalReaction(evt); + } - // Try to DM the user info about the message + 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.System.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 _cache.GetOrCreateDmChannel(_rest, evt.UserId); - await _rest.CreateMessage(dm.Id, new MessageRequest - { - Embed = await _embeds.CreateMemberEmbed(msg.System, msg.Member, guild, LookupContext.ByNonOwner) - }); - - await _rest.CreateMessage(dm.Id, new MessageRequest - { - Embed = await _embeds.CreateMessageInfoEmbed(msg, true) - }); - } - 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.System.PingsEnabled) - { - // If the system has pings enabled, go ahead - await _rest.CreateMessage(evt.ChannelId, new() - { - Content = $"Psst, **{msg.Member.DisplayName()}** (<@{msg.Message.Sender}>), you have been pinged by <@{evt.UserId}>.", - Components = new[] + await _rest.CreateMessage(dm.Id, + new MessageRequest { - 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 _cache.GetOrCreateDmChannel(_rest, evt.UserId); - await _rest.CreateMessage(dm.Id, 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:" + 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.Id, new MessageRequest { Content = $"<@{msg.Message.Sender}>".AsCode() }); - } - catch (ForbiddenException) { } + await _rest.CreateMessage( + dm.Id, + new MessageRequest { Content = $"<@{msg.Message.Sender}>".AsCode() } + ); } + catch (ForbiddenException) { } - await TryRemoveOriginalReaction(evt); - } + 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); - } + 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 a1e3d54e..8f87b31e 100644 --- a/PluralKit.Bot/Init.cs +++ b/PluralKit.Bot/Init.cs @@ -1,8 +1,3 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - using Autofac; using Microsoft.Extensions.Configuration; @@ -17,157 +12,154 @@ using Sentry; using Serilog; using Serilog.Core; -namespace PluralKit.Bot +namespace PluralKit.Bot; + +public class Init { - public class Init + private static Task Main(string[] args) { - 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 => { - // 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); - - return RunWrapper(services, async ct => - { - // init version service - await BuildInfoService.LoadVersion(); - - var logger = services.Resolve().ForContext(); - - // Initialize Sentry SDK, and make sure it gets dropped at the end - - using var _ = Sentry.SentrySdk.Init((opts) => - { - opts.Dsn = services.Resolve().SentryUrl; - opts.Release = BuildInfoService.FullVersion; - opts.AutoSessionTracking = true; - opts.DisableTaskUnobservedTaskExceptionCapture(); - }); - - // "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(); - - // 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 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(); - } - }); - } - - 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(); - var shutdown = new TaskCompletionSource(); - var gracefulShutdownCts = new CancellationTokenSource(); + // Initialize Sentry SDK, and make sure it gets dropped at the end - Console.CancelKeyPress += delegate + using var _ = SentrySdk.Init(opts => { - // ReSharper disable once AccessToDisposedClosure (will only be hit before the below disposal) - logger.Information("Received SIGINT/Ctrl-C, attempting graceful shutdown..."); - gracefulShutdownCts.Cancel(); - }; + opts.Dsn = services.Resolve().SentryUrl; + opts.Release = BuildInfoService.FullVersion; + opts.AutoSessionTracking = true; + opts.DisableTaskUnobservedTaskExceptionCapture(); + }); - 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. + // "Connect to the database" (ie. set off database migrations and ensure state) + logger.Information("Connecting to database"); + await services.Resolve().ApplyMigrations(); - // 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... - }; + // 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(); + // 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) + 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", cfg => { - var builder = new ContainerBuilder(); - builder.RegisterInstance(config); - builder.RegisterModule(new ConfigModule("Bot")); - builder.RegisterModule(new LoggingModule("bot", cfg => - { - // TODO: do we need this? - // cfg.Destructure.With(); - })); - builder.RegisterModule(new MetricsModule()); - builder.RegisterModule(); - builder.RegisterModule(); - return builder.Build(); + // TODO: do we need this? + // cfg.Destructure.With(); + })); + builder.RegisterModule(new MetricsModule()); + builder.RegisterModule(); + builder.RegisterModule(); + return builder.Build(); + } + + private static async Task StartCluster(IComponentContext services) + { + var info = await services.Resolve().GetGatewayBot(); + + var cluster = services.Resolve(); + var config = services.Resolve(); + + if (config.Cluster != null) + { + // 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 = ExtractNodeIndex(config.Cluster.NodeName); + + // 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; + + await cluster.Start(info.Url, shardMin, shardMax, totalShards, info.SessionStartLimit.MaxConcurrency); } - - private static async Task StartCluster(IComponentContext services) + else { - var info = await services.Resolve().GetGatewayBot(); - - var cluster = services.Resolve(); - var config = services.Resolve(); - - if (config.Cluster != null) - { - // 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 = ExtractNodeIndex(config.Cluster.NodeName); - - // 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; - - await cluster.Start(info.Url, shardMin, shardMax, totalShards, info.SessionStartLimit.MaxConcurrency); - } - else - { - await cluster.Start(info); - } - } - - private static int ExtractNodeIndex(string nodeName) - { - // Node name eg. "pluralkit-3", want to extract the 3. blame k8s :p - return int.Parse(nodeName.Split("-").Last()); + await cluster.Start(info); } } + + private static int ExtractNodeIndex(string nodeName) => + // Node name eg. "pluralkit-3", want to extract the 3. blame k8s :p + int.Parse(nodeName.Split("-").Last()); } \ No newline at end of file diff --git a/PluralKit.Bot/Interactive/BaseInteractive.cs b/PluralKit.Bot/Interactive/BaseInteractive.cs index 53377a8f..c1bd3670 100644 --- a/PluralKit.Bot/Interactive/BaseInteractive.cs +++ b/PluralKit.Bot/Interactive/BaseInteractive.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - using Autofac; using Myriad.Rest.Types; @@ -12,122 +6,119 @@ using Myriad.Types; using NodaTime; -namespace PluralKit.Bot.Interactive +namespace PluralKit.Bot.Interactive; + +public abstract class BaseInteractive { - public abstract class BaseInteractive + protected readonly List