From a6fbd869be4e9dd931cb48f4970f697f516030e5 Mon Sep 17 00:00:00 2001 From: Ske Date: Tue, 22 Dec 2020 13:15:26 +0100 Subject: [PATCH 01/26] Initial commit, basic proxying working --- Myriad/Cache/DiscordCacheExtensions.cs | 50 +++ Myriad/Cache/IDiscordCache.cs | 28 ++ Myriad/Cache/MemoryDiscordCache.cs | 143 ++++++++ Myriad/Extensions/ChannelExtensions.cs | 7 + Myriad/Extensions/MessageExtensions.cs | 7 + Myriad/Extensions/PermissionExtensions.cs | 126 +++++++ Myriad/Extensions/UserExtensions.cs | 10 + Myriad/Gateway/Cluster.cs | 88 +++++ Myriad/Gateway/ClusterSessionState.cs | 15 + Myriad/Gateway/Events/ChannelCreateEvent.cs | 6 + Myriad/Gateway/Events/ChannelDeleteEvent.cs | 6 + Myriad/Gateway/Events/ChannelUpdateEvent.cs | 6 + Myriad/Gateway/Events/GuildCreateEvent.cs | 12 + Myriad/Gateway/Events/GuildDeleteEvent.cs | 4 + Myriad/Gateway/Events/GuildMemberAddEvent.cs | 9 + .../Gateway/Events/GuildMemberRemoveEvent.cs | 10 + .../Gateway/Events/GuildMemberUpdateEvent.cs | 9 + Myriad/Gateway/Events/GuildRoleCreateEvent.cs | 6 + Myriad/Gateway/Events/GuildRoleDeleteEvent.cs | 4 + Myriad/Gateway/Events/GuildRoleUpdateEvent.cs | 6 + Myriad/Gateway/Events/GuildUpdateEvent.cs | 6 + Myriad/Gateway/Events/IGatewayEvent.cs | 35 ++ .../Gateway/Events/InteractionCreateEvent.cs | 6 + Myriad/Gateway/Events/MessageCreateEvent.cs | 9 + .../Gateway/Events/MessageDeleteBulkEvent.cs | 4 + Myriad/Gateway/Events/MessageDeleteEvent.cs | 4 + .../Gateway/Events/MessageReactionAddEvent.cs | 8 + .../Events/MessageReactionRemoveAllEvent.cs | 4 + .../Events/MessageReactionRemoveEmojiEvent.cs | 7 + .../Events/MessageReactionRemoveEvent.cs | 7 + Myriad/Gateway/Events/MessageUpdateEvent.cs | 7 + Myriad/Gateway/Events/ReadyEvent.cs | 15 + Myriad/Gateway/Events/ResumedEvent.cs | 4 + Myriad/Gateway/GatewayCloseException.cs | 35 ++ Myriad/Gateway/GatewayIntent.cs | 24 ++ Myriad/Gateway/GatewayPacket.cs | 31 ++ Myriad/Gateway/GatewaySettings.cs | 8 + Myriad/Gateway/Payloads/GatewayHello.cs | 4 + Myriad/Gateway/Payloads/GatewayIdentify.cs | 28 ++ Myriad/Gateway/Payloads/GatewayResume.cs | 4 + .../Gateway/Payloads/GatewayStatusUpdate.cs | 23 ++ Myriad/Gateway/Shard.cs | 328 ++++++++++++++++++ Myriad/Gateway/ShardConnection.cs | 118 +++++++ Myriad/Gateway/ShardInfo.cs | 4 + Myriad/Gateway/ShardSessionInfo.cs | 8 + Myriad/Myriad.csproj | 19 + Myriad/Rest/BaseRestClient.cs | 240 +++++++++++++ Myriad/Rest/DiscordApiClient.cs | 120 +++++++ Myriad/Rest/DiscordApiError.cs | 9 + .../Exceptions/DiscordRequestException.cs | 71 ++++ Myriad/Rest/Exceptions/RatelimitException.cs | 29 ++ Myriad/Rest/Ratelimit/Bucket.cs | 152 ++++++++ Myriad/Rest/Ratelimit/BucketManager.cs | 79 +++++ .../Rest/Ratelimit/DiscordRateLimitPolicy.cs | 46 +++ Myriad/Rest/Ratelimit/RatelimitHeaders.cs | 46 +++ Myriad/Rest/Ratelimit/Ratelimiter.cs | 86 +++++ Myriad/Rest/Types/AllowedMentions.cs | 19 + Myriad/Rest/Types/MultipartFile.cs | 6 + Myriad/Rest/Types/Requests/CommandRequest.cs | 13 + .../Types/Requests/CreateWebhookRequest.cs | 4 + .../Types/Requests/ExecuteWebhookRequest.cs | 13 + .../Rest/Types/Requests/MessageEditRequest.cs | 10 + Myriad/Rest/Types/Requests/MessageRequest.cs | 13 + .../Requests/ModifyGuildMemberRequest.cs | 7 + .../JsonSerializerOptionsExtensions.cs | 20 ++ .../JsonSnakeCaseNamingPolicy.cs | 88 +++++ Myriad/Serialization/JsonStringConverter.cs | 22 ++ .../PermissionSetJsonConverter.cs | 24 ++ .../Serialization/ShardInfoJsonConverter.cs | 28 ++ Myriad/Types/Activity.cs | 22 ++ Myriad/Types/Application/Application.cs | 27 ++ .../Types/Application/ApplicationCommand.cs | 13 + .../ApplicationCommandInteractionData.cs | 9 + ...ApplicationCommandInteractionDataOption.cs | 9 + .../Application/ApplicationCommandOption.cs | 24 ++ Myriad/Types/Application/Interaction.cs | 19 + ...teractionApplicationCommandCallbackData.cs | 15 + .../Types/Application/InteractionResponse.cs | 17 + Myriad/Types/Channel.cs | 40 +++ Myriad/Types/Embed.cs | 64 ++++ Myriad/Types/Emoji.cs | 9 + Myriad/Types/Gateway/GatewayInfo.cs | 13 + Myriad/Types/Gateway/SessionStartLimit.cs | 9 + Myriad/Types/Guild.cs | 24 ++ Myriad/Types/GuildMember.cs | 14 + Myriad/Types/Message.cs | 85 +++++ Myriad/Types/PermissionSet.cs | 47 +++ Myriad/Types/Permissions.cs | 6 + Myriad/Types/Role.cs | 14 + Myriad/Types/User.cs | 38 ++ Myriad/Types/Webhook.cs | 21 ++ PluralKit.Bot/Bot.cs | 171 ++++++--- PluralKit.Bot/CommandSystem/Context.cs | 41 ++- PluralKit.Bot/Handlers/IEventHandler.cs | 10 +- PluralKit.Bot/Handlers/MessageCreated.cs | 87 +++-- PluralKit.Bot/Handlers/MessageDeleted.cs | 18 +- PluralKit.Bot/Handlers/MessageEdited.cs | 36 +- PluralKit.Bot/Handlers/ReactionAdded.cs | 8 +- PluralKit.Bot/Init.cs | 8 +- PluralKit.Bot/Modules.cs | 44 ++- PluralKit.Bot/PluralKit.Bot.csproj | 1 + PluralKit.Bot/Proxy/ProxyService.cs | 145 ++++---- PluralKit.Bot/Services/LogChannelService.cs | 61 ++-- PluralKit.Bot/Services/LoggerCleanService.cs | 10 +- PluralKit.Bot/Services/WebhookCacheService.cs | 95 ++--- .../Services/WebhookExecutorService.cs | 123 ++++--- PluralKit.Bot/Utils/DiscordUtils.cs | 9 +- PluralKit.Bot/Utils/SentryUtils.cs | 39 ++- PluralKit.sln | 6 + 109 files changed, 3539 insertions(+), 359 deletions(-) create mode 100644 Myriad/Cache/DiscordCacheExtensions.cs create mode 100644 Myriad/Cache/IDiscordCache.cs create mode 100644 Myriad/Cache/MemoryDiscordCache.cs create mode 100644 Myriad/Extensions/ChannelExtensions.cs create mode 100644 Myriad/Extensions/MessageExtensions.cs create mode 100644 Myriad/Extensions/PermissionExtensions.cs create mode 100644 Myriad/Extensions/UserExtensions.cs create mode 100644 Myriad/Gateway/Cluster.cs create mode 100644 Myriad/Gateway/ClusterSessionState.cs create mode 100644 Myriad/Gateway/Events/ChannelCreateEvent.cs create mode 100644 Myriad/Gateway/Events/ChannelDeleteEvent.cs create mode 100644 Myriad/Gateway/Events/ChannelUpdateEvent.cs create mode 100644 Myriad/Gateway/Events/GuildCreateEvent.cs create mode 100644 Myriad/Gateway/Events/GuildDeleteEvent.cs create mode 100644 Myriad/Gateway/Events/GuildMemberAddEvent.cs create mode 100644 Myriad/Gateway/Events/GuildMemberRemoveEvent.cs create mode 100644 Myriad/Gateway/Events/GuildMemberUpdateEvent.cs create mode 100644 Myriad/Gateway/Events/GuildRoleCreateEvent.cs create mode 100644 Myriad/Gateway/Events/GuildRoleDeleteEvent.cs create mode 100644 Myriad/Gateway/Events/GuildRoleUpdateEvent.cs create mode 100644 Myriad/Gateway/Events/GuildUpdateEvent.cs create mode 100644 Myriad/Gateway/Events/IGatewayEvent.cs create mode 100644 Myriad/Gateway/Events/InteractionCreateEvent.cs create mode 100644 Myriad/Gateway/Events/MessageCreateEvent.cs create mode 100644 Myriad/Gateway/Events/MessageDeleteBulkEvent.cs create mode 100644 Myriad/Gateway/Events/MessageDeleteEvent.cs create mode 100644 Myriad/Gateway/Events/MessageReactionAddEvent.cs create mode 100644 Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs create mode 100644 Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs create mode 100644 Myriad/Gateway/Events/MessageReactionRemoveEvent.cs create mode 100644 Myriad/Gateway/Events/MessageUpdateEvent.cs create mode 100644 Myriad/Gateway/Events/ReadyEvent.cs create mode 100644 Myriad/Gateway/Events/ResumedEvent.cs create mode 100644 Myriad/Gateway/GatewayCloseException.cs create mode 100644 Myriad/Gateway/GatewayIntent.cs create mode 100644 Myriad/Gateway/GatewayPacket.cs create mode 100644 Myriad/Gateway/GatewaySettings.cs create mode 100644 Myriad/Gateway/Payloads/GatewayHello.cs create mode 100644 Myriad/Gateway/Payloads/GatewayIdentify.cs create mode 100644 Myriad/Gateway/Payloads/GatewayResume.cs create mode 100644 Myriad/Gateway/Payloads/GatewayStatusUpdate.cs create mode 100644 Myriad/Gateway/Shard.cs create mode 100644 Myriad/Gateway/ShardConnection.cs create mode 100644 Myriad/Gateway/ShardInfo.cs create mode 100644 Myriad/Gateway/ShardSessionInfo.cs create mode 100644 Myriad/Myriad.csproj create mode 100644 Myriad/Rest/BaseRestClient.cs create mode 100644 Myriad/Rest/DiscordApiClient.cs create mode 100644 Myriad/Rest/DiscordApiError.cs create mode 100644 Myriad/Rest/Exceptions/DiscordRequestException.cs create mode 100644 Myriad/Rest/Exceptions/RatelimitException.cs create mode 100644 Myriad/Rest/Ratelimit/Bucket.cs create mode 100644 Myriad/Rest/Ratelimit/BucketManager.cs create mode 100644 Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs create mode 100644 Myriad/Rest/Ratelimit/RatelimitHeaders.cs create mode 100644 Myriad/Rest/Ratelimit/Ratelimiter.cs create mode 100644 Myriad/Rest/Types/AllowedMentions.cs create mode 100644 Myriad/Rest/Types/MultipartFile.cs create mode 100644 Myriad/Rest/Types/Requests/CommandRequest.cs create mode 100644 Myriad/Rest/Types/Requests/CreateWebhookRequest.cs create mode 100644 Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs create mode 100644 Myriad/Rest/Types/Requests/MessageEditRequest.cs create mode 100644 Myriad/Rest/Types/Requests/MessageRequest.cs create mode 100644 Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs create mode 100644 Myriad/Serialization/JsonSerializerOptionsExtensions.cs create mode 100644 Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs create mode 100644 Myriad/Serialization/JsonStringConverter.cs create mode 100644 Myriad/Serialization/PermissionSetJsonConverter.cs create mode 100644 Myriad/Serialization/ShardInfoJsonConverter.cs create mode 100644 Myriad/Types/Activity.cs create mode 100644 Myriad/Types/Application/Application.cs create mode 100644 Myriad/Types/Application/ApplicationCommand.cs create mode 100644 Myriad/Types/Application/ApplicationCommandInteractionData.cs create mode 100644 Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs create mode 100644 Myriad/Types/Application/ApplicationCommandOption.cs create mode 100644 Myriad/Types/Application/Interaction.cs create mode 100644 Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs create mode 100644 Myriad/Types/Application/InteractionResponse.cs create mode 100644 Myriad/Types/Channel.cs create mode 100644 Myriad/Types/Embed.cs create mode 100644 Myriad/Types/Emoji.cs create mode 100644 Myriad/Types/Gateway/GatewayInfo.cs create mode 100644 Myriad/Types/Gateway/SessionStartLimit.cs create mode 100644 Myriad/Types/Guild.cs create mode 100644 Myriad/Types/GuildMember.cs create mode 100644 Myriad/Types/Message.cs create mode 100644 Myriad/Types/PermissionSet.cs create mode 100644 Myriad/Types/Permissions.cs create mode 100644 Myriad/Types/Role.cs create mode 100644 Myriad/Types/User.cs create mode 100644 Myriad/Types/Webhook.cs diff --git a/Myriad/Cache/DiscordCacheExtensions.cs b/Myriad/Cache/DiscordCacheExtensions.cs new file mode 100644 index 00000000..ff9a251f --- /dev/null +++ b/Myriad/Cache/DiscordCacheExtensions.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; + +using Myriad.Gateway; + +namespace Myriad.Cache +{ + public static class DiscordCacheExtensions + { + public static ValueTask HandleGatewayEvent(this IDiscordCache cache, IGatewayEvent evt) + { + switch (evt) + { + 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 MessageCreateEvent mc: + return cache.SaveUser(mc.Author); + } + + 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); + } + } +} \ No newline at end of file diff --git a/Myriad/Cache/IDiscordCache.cs b/Myriad/Cache/IDiscordCache.cs new file mode 100644 index 00000000..fdc348c6 --- /dev/null +++ b/Myriad/Cache/IDiscordCache.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using Myriad.Types; + +namespace Myriad.Cache +{ + public interface IDiscordCache + { + public ValueTask SaveGuild(Guild guild); + public ValueTask SaveChannel(Channel channel); + public ValueTask SaveUser(User user); + public ValueTask SaveRole(ulong guildId, Role role); + + public ValueTask RemoveGuild(ulong guildId); + public ValueTask RemoveChannel(ulong channelId); + public ValueTask RemoveUser(ulong userId); + public ValueTask RemoveRole(ulong guildId, ulong roleId); + + public ValueTask GetGuild(ulong guildId); + public ValueTask GetChannel(ulong channelId); + public ValueTask GetUser(ulong userId); + public ValueTask GetRole(ulong roleId); + + public IAsyncEnumerable GetAllGuilds(); + public ValueTask> GetGuildChannels(ulong guildId); + } +} \ No newline at end of file diff --git a/Myriad/Cache/MemoryDiscordCache.cs b/Myriad/Cache/MemoryDiscordCache.cs new file mode 100644 index 00000000..8ba50366 --- /dev/null +++ b/Myriad/Cache/MemoryDiscordCache.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Myriad.Types; + +namespace Myriad.Cache +{ + public class MemoryDiscordCache: IDiscordCache + { + private readonly ConcurrentDictionary _channels; + private readonly ConcurrentDictionary _guilds; + private readonly ConcurrentDictionary _roles; + private readonly ConcurrentDictionary _users; + + public MemoryDiscordCache() + { + _guilds = new ConcurrentDictionary(); + _channels = new ConcurrentDictionary(); + _users = new ConcurrentDictionary(); + _roles = new ConcurrentDictionary(); + } + + 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 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); + + return default; + } + + public ValueTask SaveUser(User user) + { + _users[user.Id] = user; + 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 RemoveGuild(ulong guildId) + { + _guilds.TryRemove(guildId, out _); + return default; + } + + public ValueTask RemoveChannel(ulong channelId) + { + if (!_channels.TryRemove(channelId, out var channel)) + return default; + + if (channel.GuildId != null && _guilds.TryGetValue(channel.GuildId.Value, out var guild)) + guild.Channels.TryRemove(channel.Id, out _); + + return default; + } + + public ValueTask RemoveUser(ulong userId) + { + _users.TryRemove(userId, out _); + return default; + } + + public ValueTask RemoveRole(ulong guildId, ulong roleId) + { + _roles.TryRemove(roleId, out _); + return default; + } + + public ValueTask GetGuild(ulong guildId) => new(_guilds.GetValueOrDefault(guildId)?.Guild); + + public ValueTask GetChannel(ulong channelId) => new(_channels.GetValueOrDefault(channelId)); + + public ValueTask GetUser(ulong userId) => new(_users.GetValueOrDefault(userId)); + + public ValueTask GetRole(ulong roleId) => new(_roles.GetValueOrDefault(roleId)); + + public async IAsyncEnumerable GetAllGuilds() + { + foreach (var guild in _guilds.Values) + yield return guild.Guild; + } + + public ValueTask> GetGuildChannels(ulong guildId) + { + if (!_guilds.TryGetValue(guildId, out var guild)) + throw new ArgumentException("Guild not found", nameof(guildId)); + + return new ValueTask>(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(); + } + } +} \ No newline at end of file diff --git a/Myriad/Extensions/ChannelExtensions.cs b/Myriad/Extensions/ChannelExtensions.cs new file mode 100644 index 00000000..99344138 --- /dev/null +++ b/Myriad/Extensions/ChannelExtensions.cs @@ -0,0 +1,7 @@ +namespace Myriad.Extensions +{ + public static class ChannelExtensions + { + + } +} \ No newline at end of file diff --git a/Myriad/Extensions/MessageExtensions.cs b/Myriad/Extensions/MessageExtensions.cs new file mode 100644 index 00000000..ef999fc0 --- /dev/null +++ b/Myriad/Extensions/MessageExtensions.cs @@ -0,0 +1,7 @@ +namespace Myriad.Extensions +{ + public class MessageExtensions + { + + } +} \ No newline at end of file diff --git a/Myriad/Extensions/PermissionExtensions.cs b/Myriad/Extensions/PermissionExtensions.cs new file mode 100644 index 00000000..02fd3292 --- /dev/null +++ b/Myriad/Extensions/PermissionExtensions.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Myriad.Gateway; +using Myriad.Types; + +namespace Myriad.Extensions +{ + public static class PermissionExtensions + { + public static PermissionSet EveryonePermissions(this Guild guild) => + guild.Roles.FirstOrDefault(r => r.Id == guild.Id)?.Permissions ?? PermissionSet.Dm; + + public static PermissionSet PermissionsFor(Guild guild, Channel channel, MessageCreateEvent msg) => + PermissionsFor(guild, channel, msg.Author.Id, msg.Member!.Roles); + + public static PermissionSet PermissionsFor(Guild guild, Channel channel, ulong userId, + ICollection roleIds) + { + if (channel.Type == Channel.ChannelType.Dm) + return PermissionSet.Dm; + + var perms = GuildPermissions(guild, userId, roleIds); + perms = ApplyChannelOverwrites(perms, channel, userId, roleIds); + + 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 bool Has(this PermissionSet value, PermissionSet flag) => + (value & flag) == flag; + + 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.Has(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; + } +} \ No newline at end of file diff --git a/Myriad/Extensions/UserExtensions.cs b/Myriad/Extensions/UserExtensions.cs new file mode 100644 index 00000000..1f31b231 --- /dev/null +++ b/Myriad/Extensions/UserExtensions.cs @@ -0,0 +1,10 @@ +using Myriad.Types; + +namespace Myriad.Extensions +{ + public static class UserExtensions + { + public static string AvatarUrl(this User user) => + $"https://cdn.discordapp.com/avatars/{user.Id}/{user.Avatar}.png"; + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Cluster.cs b/Myriad/Gateway/Cluster.cs new file mode 100644 index 00000000..304cfb8a --- /dev/null +++ b/Myriad/Gateway/Cluster.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Myriad.Types; + +using Serilog; + +namespace Myriad.Gateway +{ + public class Cluster + { + private readonly GatewaySettings _gatewaySettings; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _shards = new(); + + public Cluster(GatewaySettings gatewaySettings, ILogger logger) + { + _gatewaySettings = gatewaySettings; + _logger = logger; + } + + public Func? EventReceived { get; set; } + + public IReadOnlyDictionary Shards => _shards; + public ClusterSessionState SessionState => GetClusterState(); + public User? User => _shards.Values.Select(s => s.User).FirstOrDefault(s => s != null); + + private ClusterSessionState GetClusterState() + { + var shards = new List(); + foreach (var (id, shard) in _shards) + shards.Add(new ClusterSessionState.ShardState + { + Shard = shard.ShardInfo ?? new ShardInfo(id, _shards.Count), Session = shard.SessionInfo + }); + + return new ClusterSessionState {Shards = shards}; + } + + public async Task Start(GatewayInfo.Bot info, ClusterSessionState? lastState = null) + { + if (lastState != null && lastState.Shards.Count == info.Shards) + await Resume(info.Url, lastState); + else + await Start(info.Url, info.Shards); + } + + public async Task Resume(string url, ClusterSessionState sessionState) + { + _logger.Information("Resuming session with {ShardCount} shards at {Url}", sessionState.Shards.Count, url); + foreach (var shardState in sessionState.Shards) + CreateAndAddShard(url, shardState.Shard, shardState.Session); + + await StartShards(); + } + + public async Task Start(string url, int shardCount) + { + _logger.Information("Starting {ShardCount} shards at {Url}", shardCount, url); + for (var i = 0; i < shardCount; i++) + CreateAndAddShard(url, new ShardInfo(i, shardCount), null); + + await StartShards(); + } + + private async Task StartShards() + { + _logger.Information("Connecting shards..."); + await Task.WhenAll(_shards.Values.Select(s => s.Start())); + } + + private void CreateAndAddShard(string url, ShardInfo shardInfo, ShardSessionInfo? session) + { + var shard = new Shard(_logger, new Uri(url), _gatewaySettings, shardInfo, session); + shard.OnEventReceived += evt => OnShardEventReceived(shard, evt); + _shards[shardInfo.ShardId] = shard; + } + + private async Task OnShardEventReceived(Shard shard, IGatewayEvent evt) + { + if (EventReceived != null) + await EventReceived(shard, evt); + } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/ClusterSessionState.cs b/Myriad/Gateway/ClusterSessionState.cs new file mode 100644 index 00000000..aafb14be --- /dev/null +++ b/Myriad/Gateway/ClusterSessionState.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Myriad.Gateway +{ + public record ClusterSessionState + { + public List Shards { get; init; } + + public record ShardState + { + public ShardInfo Shard { get; init; } + public ShardSessionInfo Session { get; init; } + } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ChannelCreateEvent.cs b/Myriad/Gateway/Events/ChannelCreateEvent.cs new file mode 100644 index 00000000..08c7f4aa --- /dev/null +++ b/Myriad/Gateway/Events/ChannelCreateEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record ChannelCreateEvent: Channel, IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ChannelDeleteEvent.cs b/Myriad/Gateway/Events/ChannelDeleteEvent.cs new file mode 100644 index 00000000..7a3907b9 --- /dev/null +++ b/Myriad/Gateway/Events/ChannelDeleteEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record ChannelDeleteEvent: Channel, IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ChannelUpdateEvent.cs b/Myriad/Gateway/Events/ChannelUpdateEvent.cs new file mode 100644 index 00000000..95b675ac --- /dev/null +++ b/Myriad/Gateway/Events/ChannelUpdateEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record ChannelUpdateEvent: Channel, IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildCreateEvent.cs b/Myriad/Gateway/Events/GuildCreateEvent.cs new file mode 100644 index 00000000..acfc9132 --- /dev/null +++ b/Myriad/Gateway/Events/GuildCreateEvent.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record GuildCreateEvent: Guild, IGatewayEvent + { + public Channel[] Channels { get; init; } + public GuildMember[] Members { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildDeleteEvent.cs b/Myriad/Gateway/Events/GuildDeleteEvent.cs new file mode 100644 index 00000000..a46be10b --- /dev/null +++ b/Myriad/Gateway/Events/GuildDeleteEvent.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record GuildDeleteEvent(ulong Id, bool Unavailable): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildMemberAddEvent.cs b/Myriad/Gateway/Events/GuildMemberAddEvent.cs new file mode 100644 index 00000000..33bcb057 --- /dev/null +++ b/Myriad/Gateway/Events/GuildMemberAddEvent.cs @@ -0,0 +1,9 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record GuildMemberAddEvent: GuildMember, IGatewayEvent + { + public ulong GuildId { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildMemberRemoveEvent.cs b/Myriad/Gateway/Events/GuildMemberRemoveEvent.cs new file mode 100644 index 00000000..713dd85a --- /dev/null +++ b/Myriad/Gateway/Events/GuildMemberRemoveEvent.cs @@ -0,0 +1,10 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public class GuildMemberRemoveEvent: IGatewayEvent + { + public ulong GuildId { get; init; } + public User User { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildMemberUpdateEvent.cs b/Myriad/Gateway/Events/GuildMemberUpdateEvent.cs new file mode 100644 index 00000000..61f5b828 --- /dev/null +++ b/Myriad/Gateway/Events/GuildMemberUpdateEvent.cs @@ -0,0 +1,9 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record GuildMemberUpdateEvent: GuildMember, IGatewayEvent + { + public ulong GuildId { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildRoleCreateEvent.cs b/Myriad/Gateway/Events/GuildRoleCreateEvent.cs new file mode 100644 index 00000000..4c5079fc --- /dev/null +++ b/Myriad/Gateway/Events/GuildRoleCreateEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record GuildRoleCreateEvent(ulong GuildId, Role Role): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildRoleDeleteEvent.cs b/Myriad/Gateway/Events/GuildRoleDeleteEvent.cs new file mode 100644 index 00000000..082c56df --- /dev/null +++ b/Myriad/Gateway/Events/GuildRoleDeleteEvent.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record GuildRoleDeleteEvent(ulong GuildId, ulong RoleId): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildRoleUpdateEvent.cs b/Myriad/Gateway/Events/GuildRoleUpdateEvent.cs new file mode 100644 index 00000000..298769ca --- /dev/null +++ b/Myriad/Gateway/Events/GuildRoleUpdateEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record GuildRoleUpdateEvent(ulong GuildId, Role Role): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/GuildUpdateEvent.cs b/Myriad/Gateway/Events/GuildUpdateEvent.cs new file mode 100644 index 00000000..5d4695db --- /dev/null +++ b/Myriad/Gateway/Events/GuildUpdateEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record GuildUpdateEvent: Guild, IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/IGatewayEvent.cs b/Myriad/Gateway/Events/IGatewayEvent.cs new file mode 100644 index 00000000..17c5068b --- /dev/null +++ b/Myriad/Gateway/Events/IGatewayEvent.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace Myriad.Gateway +{ + public interface IGatewayEvent + { + public static readonly Dictionary EventTypes = new() + { + {"READY", typeof(ReadyEvent)}, + {"RESUMED", typeof(ResumedEvent)}, + {"GUILD_CREATE", typeof(GuildCreateEvent)}, + {"GUILD_UPDATE", typeof(GuildUpdateEvent)}, + {"GUILD_DELETE", typeof(GuildDeleteEvent)}, + {"GUILD_MEMBER_ADD", typeof(GuildMemberAddEvent)}, + {"GUILD_MEMBER_REMOVE", typeof(GuildMemberRemoveEvent)}, + {"GUILD_MEMBER_UPDATE", typeof(GuildMemberUpdateEvent)}, + {"GUILD_ROLE_CREATE", typeof(GuildRoleCreateEvent)}, + {"GUILD_ROLE_UPDATE", typeof(GuildRoleUpdateEvent)}, + {"GUILD_ROLE_DELETE", typeof(GuildRoleDeleteEvent)}, + {"CHANNEL_CREATE", typeof(ChannelCreateEvent)}, + {"CHANNEL_UPDATE", typeof(ChannelUpdateEvent)}, + {"CHANNEL_DELETE", typeof(ChannelDeleteEvent)}, + {"MESSAGE_CREATE", typeof(MessageCreateEvent)}, + {"MESSAGE_UPDATE", typeof(MessageUpdateEvent)}, + {"MESSAGE_DELETE", typeof(MessageDeleteEvent)}, + {"MESSAGE_DELETE_BULK", typeof(MessageDeleteBulkEvent)}, + {"MESSAGE_REACTION_ADD", typeof(MessageReactionAddEvent)}, + {"MESSAGE_REACTION_REMOVE", typeof(MessageReactionRemoveEvent)}, + {"MESSAGE_REACTION_REMOVE_ALL", typeof(MessageReactionRemoveAllEvent)}, + {"MESSAGE_REACTION_REMOVE_EMOJI", typeof(MessageReactionRemoveEmojiEvent)}, + {"INTERACTION_CREATE", typeof(InteractionCreateEvent)} + }; + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/InteractionCreateEvent.cs b/Myriad/Gateway/Events/InteractionCreateEvent.cs new file mode 100644 index 00000000..5ffccabc --- /dev/null +++ b/Myriad/Gateway/Events/InteractionCreateEvent.cs @@ -0,0 +1,6 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record InteractionCreateEvent: Interaction, IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageCreateEvent.cs b/Myriad/Gateway/Events/MessageCreateEvent.cs new file mode 100644 index 00000000..6df58ad4 --- /dev/null +++ b/Myriad/Gateway/Events/MessageCreateEvent.cs @@ -0,0 +1,9 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record MessageCreateEvent: Message, IGatewayEvent + { + public GuildMemberPartial? Member { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageDeleteBulkEvent.cs b/Myriad/Gateway/Events/MessageDeleteBulkEvent.cs new file mode 100644 index 00000000..b4b88601 --- /dev/null +++ b/Myriad/Gateway/Events/MessageDeleteBulkEvent.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record MessageDeleteBulkEvent(ulong[] Ids, ulong ChannelId, ulong? GuildId): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageDeleteEvent.cs b/Myriad/Gateway/Events/MessageDeleteEvent.cs new file mode 100644 index 00000000..e7a05e04 --- /dev/null +++ b/Myriad/Gateway/Events/MessageDeleteEvent.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record MessageDeleteEvent(ulong Id, ulong ChannelId, ulong? GuildId): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageReactionAddEvent.cs b/Myriad/Gateway/Events/MessageReactionAddEvent.cs new file mode 100644 index 00000000..c7545bea --- /dev/null +++ b/Myriad/Gateway/Events/MessageReactionAddEvent.cs @@ -0,0 +1,8 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record MessageReactionAddEvent(ulong UserId, ulong ChannelId, ulong MessageId, ulong? GuildId, + GuildMember? Member, + Emoji Emoji): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs b/Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs new file mode 100644 index 00000000..1ef0dab0 --- /dev/null +++ b/Myriad/Gateway/Events/MessageReactionRemoveAllEvent.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record MessageReactionRemoveAllEvent(ulong ChannelId, ulong MessageId, ulong? GuildId): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs b/Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs new file mode 100644 index 00000000..ff4a5dad --- /dev/null +++ b/Myriad/Gateway/Events/MessageReactionRemoveEmojiEvent.cs @@ -0,0 +1,7 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record MessageReactionRemoveEmojiEvent + (ulong ChannelId, ulong MessageId, ulong? GuildId, Emoji Emoji): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageReactionRemoveEvent.cs b/Myriad/Gateway/Events/MessageReactionRemoveEvent.cs new file mode 100644 index 00000000..392e2cf9 --- /dev/null +++ b/Myriad/Gateway/Events/MessageReactionRemoveEvent.cs @@ -0,0 +1,7 @@ +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record MessageReactionRemoveEvent + (ulong UserId, ulong ChannelId, ulong MessageId, ulong? GuildId, Emoji Emoji): IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/MessageUpdateEvent.cs b/Myriad/Gateway/Events/MessageUpdateEvent.cs new file mode 100644 index 00000000..9e77d076 --- /dev/null +++ b/Myriad/Gateway/Events/MessageUpdateEvent.cs @@ -0,0 +1,7 @@ +namespace Myriad.Gateway +{ + public record MessageUpdateEvent(ulong Id, ulong ChannelId): IGatewayEvent + { + // TODO: lots of partials + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ReadyEvent.cs b/Myriad/Gateway/Events/ReadyEvent.cs new file mode 100644 index 00000000..7dad1ee7 --- /dev/null +++ b/Myriad/Gateway/Events/ReadyEvent.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record ReadyEvent: IGatewayEvent + { + [JsonPropertyName("v")] public int Version { get; init; } + public User User { get; init; } + public string SessionId { get; init; } + public ShardInfo? Shard { get; init; } + public ApplicationPartial Application { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Events/ResumedEvent.cs b/Myriad/Gateway/Events/ResumedEvent.cs new file mode 100644 index 00000000..de8ecfe1 --- /dev/null +++ b/Myriad/Gateway/Events/ResumedEvent.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record ResumedEvent: IGatewayEvent; +} \ No newline at end of file diff --git a/Myriad/Gateway/GatewayCloseException.cs b/Myriad/Gateway/GatewayCloseException.cs new file mode 100644 index 00000000..02f83159 --- /dev/null +++ b/Myriad/Gateway/GatewayCloseException.cs @@ -0,0 +1,35 @@ +using System; + +namespace Myriad.Gateway +{ + // TODO: unused? + public class GatewayCloseException: Exception + { + public GatewayCloseException(int closeCode, string closeReason): base($"{closeCode}: {closeReason}") + { + CloseCode = closeCode; + CloseReason = closeReason; + } + + public int CloseCode { get; } + public string CloseReason { get; } + } + + public class GatewayCloseCode + { + public const int UnknownError = 4000; + public const int UnknownOpcode = 4001; + public const int DecodeError = 4002; + public const int NotAuthenticated = 4003; + public const int AuthenticationFailed = 4004; + public const int AlreadyAuthenticated = 4005; + public const int InvalidSeq = 4007; + public const int RateLimited = 4008; + public const int SessionTimedOut = 4009; + public const int InvalidShard = 4010; + public const int ShardingRequired = 4011; + public const int InvalidApiVersion = 4012; + public const int InvalidIntent = 4013; + public const int DisallowedIntent = 4014; + } +} \ No newline at end of file diff --git a/Myriad/Gateway/GatewayIntent.cs b/Myriad/Gateway/GatewayIntent.cs new file mode 100644 index 00000000..1a2c7c7d --- /dev/null +++ b/Myriad/Gateway/GatewayIntent.cs @@ -0,0 +1,24 @@ +using System; + +namespace Myriad.Gateway +{ + [Flags] + public enum GatewayIntent + { + Guilds = 1 << 0, + GuildMembers = 1 << 1, + GuildBans = 1 << 2, + GuildEmojis = 1 << 3, + GuildIntegrations = 1 << 4, + GuildWebhooks = 1 << 5, + GuildInvites = 1 << 6, + GuildVoiceStates = 1 << 7, + GuildPresences = 1 << 8, + GuildMessages = 1 << 9, + GuildMessageReactions = 1 << 10, + GuildMessageTyping = 1 << 11, + DirectMessages = 1 << 12, + DirectMessageReactions = 1 << 13, + DirectMessageTyping = 1 << 14 + } +} \ No newline at end of file diff --git a/Myriad/Gateway/GatewayPacket.cs b/Myriad/Gateway/GatewayPacket.cs new file mode 100644 index 00000000..1cf7e26d --- /dev/null +++ b/Myriad/Gateway/GatewayPacket.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Myriad.Gateway +{ + public record GatewayPacket + { + [JsonPropertyName("op")] public GatewayOpcode Opcode { get; init; } + [JsonPropertyName("d")] public object? Payload { get; init; } + + [JsonPropertyName("s")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Sequence { get; init; } + + [JsonPropertyName("t")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EventType { get; init; } + } + + public enum GatewayOpcode + { + Dispatch = 0, + Heartbeat = 1, + Identify = 2, + PresenceUpdate = 3, + VoiceStateUpdate = 4, + Resume = 6, + Reconnect = 7, + RequestGuildMembers = 8, + InvalidSession = 9, + Hello = 10, + HeartbeatAck = 11 + } +} \ No newline at end of file diff --git a/Myriad/Gateway/GatewaySettings.cs b/Myriad/Gateway/GatewaySettings.cs new file mode 100644 index 00000000..fdaf13ea --- /dev/null +++ b/Myriad/Gateway/GatewaySettings.cs @@ -0,0 +1,8 @@ +namespace Myriad.Gateway +{ + public record GatewaySettings + { + public string Token { get; init; } + public GatewayIntent Intents { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayHello.cs b/Myriad/Gateway/Payloads/GatewayHello.cs new file mode 100644 index 00000000..f8593bb9 --- /dev/null +++ b/Myriad/Gateway/Payloads/GatewayHello.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record GatewayHello(int HeartbeatInterval); +} \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayIdentify.cs b/Myriad/Gateway/Payloads/GatewayIdentify.cs new file mode 100644 index 00000000..bc6d1931 --- /dev/null +++ b/Myriad/Gateway/Payloads/GatewayIdentify.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace Myriad.Gateway +{ + public record GatewayIdentify + { + public string Token { get; init; } + public ConnectionProperties Properties { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Compress { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? LargeThreshold { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ShardInfo? Shard { get; init; } + + public GatewayIntent Intents { get; init; } + + public record ConnectionProperties + { + [JsonPropertyName("$os")] public string Os { get; init; } + [JsonPropertyName("$browser")] public string Browser { get; init; } + [JsonPropertyName("$device")] public string Device { get; init; } + } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayResume.cs b/Myriad/Gateway/Payloads/GatewayResume.cs new file mode 100644 index 00000000..fd386889 --- /dev/null +++ b/Myriad/Gateway/Payloads/GatewayResume.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record GatewayResume(string Token, string SessionId, int Seq); +} \ No newline at end of file diff --git a/Myriad/Gateway/Payloads/GatewayStatusUpdate.cs b/Myriad/Gateway/Payloads/GatewayStatusUpdate.cs new file mode 100644 index 00000000..23ad01b7 --- /dev/null +++ b/Myriad/Gateway/Payloads/GatewayStatusUpdate.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +using Myriad.Types; + +namespace Myriad.Gateway +{ + public record GatewayStatusUpdate + { + 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 new file mode 100644 index 00000000..a4b65592 --- /dev/null +++ b/Myriad/Gateway/Shard.cs @@ -0,0 +1,328 @@ +using System; +using System.Net.WebSockets; +using System.Text.Json; +using System.Threading.Tasks; + +using Myriad.Serialization; +using Myriad.Types; + +using Serilog; + +namespace Myriad.Gateway +{ + public class Shard: IAsyncDisposable + { + private const string LibraryName = "Newcord Test"; + + private readonly JsonSerializerOptions _jsonSerializerOptions = + new JsonSerializerOptions().ConfigureForNewcord(); + + private readonly ILogger _logger; + private readonly Uri _uri; + + private ShardConnection? _conn; + private TimeSpan? _currentHeartbeatInterval; + private bool _hasReceivedAck; + private DateTimeOffset? _lastHeartbeatSent; + private Task _worker; + + public ShardInfo? ShardInfo { get; private set; } + public GatewaySettings Settings { get; } + public ShardSessionInfo SessionInfo { get; private set; } + public ShardState State { get; private set; } + public TimeSpan? Latency { get; private set; } + public User? User { get; private set; } + + public Func? OnEventReceived { get; set; } + + public Shard(ILogger logger, Uri uri, GatewaySettings settings, ShardInfo? info = null, + ShardSessionInfo? sessionInfo = null) + { + _logger = logger; + _uri = uri; + + Settings = settings; + ShardInfo = info; + SessionInfo = sessionInfo ?? new ShardSessionInfo(); + } + + public async ValueTask DisposeAsync() + { + if (_conn != null) + await _conn.DisposeAsync(); + } + + public Task Start() + { + _worker = MainLoop(); + return Task.CompletedTask; + } + + public async Task UpdateStatus(GatewayStatusUpdate payload) + { + if (_conn != null && _conn.State == WebSocketState.Open) + await _conn!.Send(new GatewayPacket {Opcode = GatewayOpcode.PresenceUpdate, Payload = payload}); + } + + private async Task MainLoop() + { + while (true) + try + { + _logger.Information("Connecting..."); + + State = ShardState.Connecting; + await Connect(); + + _logger.Information("Connected. Entering main loop..."); + + // Tick returns false if we need to stop and reconnect + while (await Tick(_conn!)) + await Task.Delay(TimeSpan.FromMilliseconds(1000)); + + _logger.Information("Connection closed, reconnecting..."); + State = ShardState.Closed; + } + catch (Exception e) + { + _logger.Error(e, "Error in shard state handler"); + } + } + + private async Task Tick(ShardConnection conn) + { + if (conn.State != WebSocketState.Connecting && conn.State != WebSocketState.Open) + return false; + + if (!await TickHeartbeat(conn)) + // TickHeartbeat returns false if we're disconnecting + return false; + + return true; + } + + private async Task TickHeartbeat(ShardConnection conn) + { + // If we don't need to heartbeat, do nothing + if (_lastHeartbeatSent == null || _currentHeartbeatInterval == null) + return true; + + if (DateTimeOffset.UtcNow - _lastHeartbeatSent < _currentHeartbeatInterval) + return true; + + // If we haven't received the ack in time, close w/ error + if (!_hasReceivedAck) + { + _logger.Warning( + "Did not receive heartbeat Ack from gateway within interval ({HeartbeatInterval})", + _currentHeartbeatInterval); + State = ShardState.Closing; + await conn.Disconnect(WebSocketCloseStatus.ProtocolError, "Did not receive ACK in time"); + return false; + } + + // Otherwise just send it :) + await SendHeartbeat(conn); + _hasReceivedAck = false; + return true; + } + + private async Task SendHeartbeat(ShardConnection conn) + { + _logger.Debug("Sending heartbeat"); + + await conn.Send(new GatewayPacket {Opcode = GatewayOpcode.Heartbeat, Payload = SessionInfo.LastSequence}); + _lastHeartbeatSent = DateTimeOffset.UtcNow; + } + + private async Task Connect() + { + if (_conn != null) + await _conn.DisposeAsync(); + + _currentHeartbeatInterval = null; + + _conn = new ShardConnection(_uri, _logger, _jsonSerializerOptions) {OnReceive = OnReceive}; + } + + private async Task OnReceive(GatewayPacket packet) + { + switch (packet.Opcode) + { + case GatewayOpcode.Hello: + { + await HandleHello((JsonElement) packet.Payload!); + break; + } + case GatewayOpcode.Heartbeat: + { + _logger.Debug("Received heartbeat request from shard, sending Ack"); + await _conn!.Send(new GatewayPacket {Opcode = GatewayOpcode.HeartbeatAck}); + break; + } + case GatewayOpcode.HeartbeatAck: + { + Latency = DateTimeOffset.UtcNow - _lastHeartbeatSent; + _logger.Debug("Received heartbeat Ack (latency {Latency})", Latency); + + _hasReceivedAck = true; + break; + } + case GatewayOpcode.Reconnect: + { + _logger.Information("Received Reconnect, closing and reconnecting"); + await _conn!.Disconnect(WebSocketCloseStatus.Empty, null); + break; + } + case GatewayOpcode.InvalidSession: + { + var canResume = ((JsonElement) packet.Payload!).GetBoolean(); + + // Clear session info before DCing + if (!canResume) + SessionInfo = SessionInfo with { Session = null }; + + var delay = TimeSpan.FromMilliseconds(new Random().Next(1000, 5000)); + + _logger.Information( + "Received Invalid Session (can resume? {CanResume}), reconnecting after {ReconnectDelay}", + canResume, delay); + await _conn!.Disconnect(WebSocketCloseStatus.Empty, null); + + // Will reconnect after exiting this "loop" + await Task.Delay(delay); + break; + } + case GatewayOpcode.Dispatch: + { + SessionInfo = SessionInfo with { LastSequence = packet.Sequence }; + var evt = DeserializeEvent(packet.EventType!, (JsonElement) packet.Payload!)!; + + if (evt is ReadyEvent rdy) + { + if (State == ShardState.Connecting) + await HandleReady(rdy); + else + _logger.Warning("Received Ready event in unexpected state {ShardState}, ignoring?", State); + } + else if (evt is ResumedEvent) + { + if (State == ShardState.Connecting) + await HandleResumed(); + else + _logger.Warning("Received Resumed event in unexpected state {ShardState}, ignoring?", + State); + } + + await HandleEvent(evt); + break; + } + default: + { + _logger.Debug("Received unknown gateway opcode {Opcode}", packet.Opcode); + break; + } + } + } + + private async Task HandleEvent(IGatewayEvent evt) + { + if (OnEventReceived != null) + await OnEventReceived.Invoke(evt); + } + + + private IGatewayEvent? DeserializeEvent(string eventType, JsonElement data) + { + if (!IGatewayEvent.EventTypes.TryGetValue(eventType, out var clrType)) + { + _logger.Information("Received unknown event type {EventType}", eventType); + return null; + } + + try + { + _logger.Verbose("Deserializing {EventType} to {ClrType}", eventType, clrType); + return JsonSerializer.Deserialize(data.GetRawText(), clrType, _jsonSerializerOptions) + as IGatewayEvent; + } + catch (JsonException e) + { + _logger.Error(e, "Error deserializing event {EventType} to {ClrType}", eventType, clrType); + return null; + } + } + + private Task HandleReady(ReadyEvent ready) + { + ShardInfo = ready.Shard; + SessionInfo = SessionInfo with { Session = ready.SessionId }; + User = ready.User; + State = ShardState.Open; + + return Task.CompletedTask; + } + + private Task HandleResumed() + { + State = ShardState.Open; + return Task.CompletedTask; + } + + private async Task HandleHello(JsonElement json) + { + var hello = JsonSerializer.Deserialize(json.GetRawText(), _jsonSerializerOptions)!; + _logger.Debug("Received Hello with interval {Interval} ms", hello.HeartbeatInterval); + _currentHeartbeatInterval = TimeSpan.FromMilliseconds(hello.HeartbeatInterval); + + await SendHeartbeat(_conn!); + + await SendIdentifyOrResume(); + } + + private async Task SendIdentifyOrResume() + { + if (SessionInfo.Session != null && SessionInfo.LastSequence != null) + await SendResume(SessionInfo.Session, SessionInfo.LastSequence!.Value); + else + await SendIdentify(); + } + + private async Task SendIdentify() + { + _logger.Information("Sending gateway Identify for shard {@ShardInfo}", SessionInfo); + await _conn!.Send(new GatewayPacket + { + Opcode = GatewayOpcode.Identify, + Payload = new GatewayIdentify + { + Token = Settings.Token, + Properties = new GatewayIdentify.ConnectionProperties + { + Browser = LibraryName, Device = LibraryName, Os = Environment.OSVersion.ToString() + }, + Intents = Settings.Intents, + Shard = ShardInfo + } + }); + } + + private async Task SendResume(string session, int lastSequence) + { + _logger.Information("Sending gateway Resume for session {@SessionInfo}", ShardInfo, + SessionInfo); + await _conn!.Send(new GatewayPacket + { + Opcode = GatewayOpcode.Resume, Payload = new GatewayResume(Settings.Token, session, lastSequence) + }); + } + + public enum ShardState + { + Closed, + Connecting, + Open, + Closing + } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/ShardConnection.cs b/Myriad/Gateway/ShardConnection.cs new file mode 100644 index 00000000..77453de2 --- /dev/null +++ b/Myriad/Gateway/ShardConnection.cs @@ -0,0 +1,118 @@ +using System; +using System.Buffers; +using System.IO; +using System.Net.WebSockets; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Serilog; + +namespace Myriad.Gateway +{ + public class ShardConnection: IAsyncDisposable + { + private readonly MemoryStream _bufStream = new(); + + private readonly ClientWebSocket _client = new(); + private readonly CancellationTokenSource _cts = new(); + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ILogger _logger; + private readonly Task _worker; + + public ShardConnection(Uri uri, ILogger logger, JsonSerializerOptions jsonSerializerOptions) + { + _logger = logger; + _jsonSerializerOptions = jsonSerializerOptions; + + _worker = Worker(uri); + } + + public Func? OnReceive { get; set; } + + public WebSocketState State => _client.State; + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + await _worker; + + _client.Dispose(); + await _bufStream.DisposeAsync(); + _cts.Dispose(); + } + + private async Task Worker(Uri uri) + { + var realUrl = new UriBuilder(uri) + { + Query = "v=8&encoding=json" + }.Uri; + _logger.Debug("Connecting to gateway WebSocket at {GatewayUrl}", realUrl); + await _client.ConnectAsync(realUrl, default); + + while (!_cts.IsCancellationRequested && _client.State == WebSocketState.Open) + try + { + await HandleReceive(); + } + catch (Exception e) + { + _logger.Error(e, "Error in WebSocket receive worker"); + } + } + + private async Task HandleReceive() + { + _bufStream.SetLength(0); + var result = await ReadData(_bufStream); + var data = _bufStream.GetBuffer().AsMemory(0, (int) _bufStream.Position); + + if (result.MessageType == WebSocketMessageType.Text) + await HandleReceiveData(data); + else if (result.MessageType == WebSocketMessageType.Close) + _logger.Information("WebSocket closed by server: {StatusCode} {Reason}", _client.CloseStatus, + _client.CloseStatusDescription); + } + + private async Task HandleReceiveData(Memory data) + { + var packet = JsonSerializer.Deserialize(data.Span, _jsonSerializerOptions)!; + + try + { + if (OnReceive != null) + await OnReceive.Invoke(packet); + } + catch (Exception e) + { + _logger.Error(e, "Error in gateway handler for {OpcodeType}", packet.Opcode); + } + } + + private async Task ReadData(MemoryStream stream) + { + using var buf = MemoryPool.Shared.Rent(); + ValueWebSocketReceiveResult result; + do + { + result = await _client.ReceiveAsync(buf.Memory, _cts.Token); + stream.Write(buf.Memory.Span.Slice(0, result.Count)); + } while (!result.EndOfMessage); + + return result; + } + + public async Task Send(GatewayPacket packet) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(packet, _jsonSerializerOptions); + await _client.SendAsync(bytes.AsMemory(), WebSocketMessageType.Text, true, default); + } + + public async Task Disconnect(WebSocketCloseStatus status, string? description) + { + await _client.CloseAsync(status, description, default); + _cts.Cancel(); + } + } +} \ No newline at end of file diff --git a/Myriad/Gateway/ShardInfo.cs b/Myriad/Gateway/ShardInfo.cs new file mode 100644 index 00000000..07a096f6 --- /dev/null +++ b/Myriad/Gateway/ShardInfo.cs @@ -0,0 +1,4 @@ +namespace Myriad.Gateway +{ + public record ShardInfo(int ShardId, int NumShards); +} \ No newline at end of file diff --git a/Myriad/Gateway/ShardSessionInfo.cs b/Myriad/Gateway/ShardSessionInfo.cs new file mode 100644 index 00000000..81d6ee5f --- /dev/null +++ b/Myriad/Gateway/ShardSessionInfo.cs @@ -0,0 +1,8 @@ +namespace Myriad.Gateway +{ + public record ShardSessionInfo + { + public string? Session { get; init; } + public int? LastSequence { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Myriad.csproj b/Myriad/Myriad.csproj new file mode 100644 index 00000000..2b027a76 --- /dev/null +++ b/Myriad/Myriad.csproj @@ -0,0 +1,19 @@ + + + + net5.0 + enable + + + + true + full + + + + + + + + + diff --git a/Myriad/Rest/BaseRestClient.cs b/Myriad/Rest/BaseRestClient.cs new file mode 100644 index 00000000..ad35cc0a --- /dev/null +++ b/Myriad/Rest/BaseRestClient.cs @@ -0,0 +1,240 @@ +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.Threading.Tasks; + +using Myriad.Rest.Exceptions; +using Myriad.Rest.Ratelimit; +using Myriad.Rest.Types; +using Myriad.Serialization; + +using Polly; + +using Serilog; + +namespace Myriad.Rest +{ + public class BaseRestClient: IAsyncDisposable + { + private const string ApiBaseUrl = "https://discord.com/api/v8"; + + private readonly Version _httpVersion = new(2, 0); + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ILogger _logger; + private readonly Ratelimiter _ratelimiter; + private readonly AsyncPolicy _retryPolicy; + + public BaseRestClient(string userAgent, string token, ILogger logger) + { + _logger = logger.ForContext(); + + if (!token.StartsWith("Bot ")) + token = "Bot " + token; + + Client = new HttpClient(); + Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent); + Client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", token); + + _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForNewcord(); + + _ratelimiter = new Ratelimiter(logger); + var discordPolicy = new DiscordRateLimitPolicy(_ratelimiter); + + // todo: why doesn't the timeout work? o.o + var timeoutPolicy = Policy.TimeoutAsync(TimeSpan.FromSeconds(10)); + + var waitPolicy = Policy + .Handle() + .WaitAndRetryAsync(3, + (_, e, _) => ((RatelimitBucketExhaustedException) e).RetryAfter, + (_, _, _, _) => Task.CompletedTask) + .AsAsyncPolicy(); + + _retryPolicy = Policy.WrapAsync(timeoutPolicy, waitPolicy, discordPolicy); + } + + public HttpClient Client { get; } + + public ValueTask DisposeAsync() + { + _ratelimiter.Dispose(); + Client.Dispose(); + return default; + } + + public async Task Get(string path, (string endpointName, ulong major) ratelimitParams) where T: class + { + var request = new HttpRequestMessage(HttpMethod.Get, ApiBaseUrl + path); + var response = await Send(request, 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 + { + var request = new HttpRequestMessage(HttpMethod.Post, ApiBaseUrl + path); + SetRequestJsonBody(request, body); + + var response = await Send(request, ratelimitParams); + return await ReadResponse(response); + } + + public async Task PostMultipart(string path, (string endpointName, ulong major) ratelimitParams, object? payload, MultipartFile[]? files) + where T: class + { + var request = new HttpRequestMessage(HttpMethod.Post, ApiBaseUrl + path); + SetRequestFormDataBody(request, payload, files); + + var response = await Send(request, ratelimitParams); + return await ReadResponse(response); + } + + public async Task Patch(string path, (string endpointName, ulong major) ratelimitParams, object? body) + where T: class + { + var request = new HttpRequestMessage(HttpMethod.Patch, ApiBaseUrl + path); + SetRequestJsonBody(request, body); + + var response = await Send(request, ratelimitParams); + return await ReadResponse(response); + } + + public async Task Put(string path, (string endpointName, ulong major) ratelimitParams, object? body) + where T: class + { + var request = new HttpRequestMessage(HttpMethod.Put, ApiBaseUrl + path); + SetRequestJsonBody(request, body); + + var response = await Send(request, ratelimitParams); + return await ReadResponse(response); + } + + public async Task Delete(string path, (string endpointName, ulong major) ratelimitParams) + { + var request = new HttpRequestMessage(HttpMethod.Delete, ApiBaseUrl + path); + await Send(request, 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), $"file{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(HttpRequestMessage request, + (string endpointName, ulong major) ratelimitParams, + bool ignoreNotFound = false) + { + return await _retryPolicy.ExecuteAsync(async _ => + { + _logger.Debug("Sending request: {RequestMethod} {RequestPath}", + request.Method, request.RequestUri); + + request.Version = _httpVersion; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + stopwatch.Stop(); + + _logger.Debug( + "Received response in {ResponseDurationMs} ms: {RequestMethod} {RequestPath} -> {StatusCode} {ReasonPhrase}", + stopwatch.ElapsedMilliseconds, request.Method, request.RequestUri, (int) response.StatusCode, + response.ReasonPhrase); + + await HandleApiError(response, ignoreNotFound); + + 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; + + throw await CreateDiscordException(response); + } + + private async ValueTask CreateDiscordException(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + var apiError = TryParseApiError(body); + + 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; + } + } +} \ No newline at end of file diff --git a/Myriad/Rest/DiscordApiClient.cs b/Myriad/Rest/DiscordApiClient.cs new file mode 100644 index 00000000..27588b51 --- /dev/null +++ b/Myriad/Rest/DiscordApiClient.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Net; +using System.Threading.Tasks; + +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; + +using Serilog; + +namespace Myriad.Rest +{ + public class DiscordApiClient + { + private const string UserAgent = "Test Discord Library by @Ske#6201"; + private readonly BaseRestClient _client; + + public DiscordApiClient(string token, ILogger logger) + { + _client = new BaseRestClient(UserAgent, token, logger); + } + + 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 GetUser(ulong id) => + _client.Get($"/users/{id}", ("GetUser", default)); + + public Task CreateMessage(ulong channelId, MessageRequest request) => + _client.Post($"/channels/{channelId}/messages", ("CreateMessage", channelId), request)!; + + 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 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) => + _client.PostMultipart($"/webhooks/{webhookId}/{webhookToken}", + ("ExecuteWebhook", webhookId), request, files)!; + + private static string EncodeEmoji(Emoji emoji) => + WebUtility.UrlEncode(emoji.Name) ?? emoji.Id?.ToString() ?? + throw new ArgumentException("Could not encode emoji"); + } +} \ No newline at end of file diff --git a/Myriad/Rest/DiscordApiError.cs b/Myriad/Rest/DiscordApiError.cs new file mode 100644 index 00000000..59cce46b --- /dev/null +++ b/Myriad/Rest/DiscordApiError.cs @@ -0,0 +1,9 @@ +using System.Text.Json; + +namespace Myriad.Rest +{ + public record DiscordApiError(string Message, int Code) + { + public JsonElement? Errors { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Exceptions/DiscordRequestException.cs b/Myriad/Rest/Exceptions/DiscordRequestException.cs new file mode 100644 index 00000000..6570ad81 --- /dev/null +++ b/Myriad/Rest/Exceptions/DiscordRequestException.cs @@ -0,0 +1,71 @@ +using System; +using System.Net; +using System.Net.Http; + +namespace Myriad.Rest.Exceptions +{ + public class DiscordRequestException: Exception + { + public DiscordRequestException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError) + { + RequestBody = requestBody; + Response = response; + ApiError = apiError; + } + + public string RequestBody { 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 requestBody, DiscordApiError? apiError): base( + response, requestBody, apiError) { } + } + + public class UnauthorizedException: DiscordRequestException + { + public UnauthorizedException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( + response, requestBody, apiError) { } + } + + public class ForbiddenException: DiscordRequestException + { + public ForbiddenException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( + response, requestBody, apiError) { } + } + + public class ConflictException: DiscordRequestException + { + public ConflictException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( + response, requestBody, apiError) { } + } + + public class BadRequestException: DiscordRequestException + { + public BadRequestException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( + response, requestBody, apiError) { } + } + + public class TooManyRequestsException: DiscordRequestException + { + public TooManyRequestsException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): + base(response, requestBody, apiError) { } + } + + public class UnknownDiscordRequestException: DiscordRequestException + { + public UnknownDiscordRequestException(HttpResponseMessage response, string requestBody, + DiscordApiError? apiError): base(response, requestBody, apiError) { } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Exceptions/RatelimitException.cs b/Myriad/Rest/Exceptions/RatelimitException.cs new file mode 100644 index 00000000..780f45ea --- /dev/null +++ b/Myriad/Rest/Exceptions/RatelimitException.cs @@ -0,0 +1,29 @@ +using System; + +using Myriad.Rest.Ratelimit; + +namespace Myriad.Rest.Exceptions +{ + public class RatelimitException: Exception + { + public RatelimitException(string? message): base(message) { } + } + + public class RatelimitBucketExhaustedException: RatelimitException + { + public RatelimitBucketExhaustedException(Bucket bucket, TimeSpan retryAfter): base( + "Rate limit bucket exhausted, request blocked") + { + Bucket = bucket; + RetryAfter = retryAfter; + } + + public Bucket Bucket { get; } + public TimeSpan RetryAfter { get; } + } + + public class GloballyRatelimitedException: RatelimitException + { + public GloballyRatelimitedException(): base("Global rate limit hit") { } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/Bucket.cs b/Myriad/Rest/Ratelimit/Bucket.cs new file mode 100644 index 00000000..31e7ea24 --- /dev/null +++ b/Myriad/Rest/Ratelimit/Bucket.cs @@ -0,0 +1,152 @@ +using System; +using System.Threading; + +using Serilog; + +namespace Myriad.Rest.Ratelimit +{ + public class Bucket + { + private static readonly TimeSpan Epsilon = TimeSpan.FromMilliseconds(10); + private static readonly TimeSpan FallbackDelay = TimeSpan.FromMilliseconds(200); + + private static readonly TimeSpan StaleTimeout = TimeSpan.FromSeconds(5); + + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + private DateTimeOffset _nextReset; + private bool _resetTimeValid; + + public Bucket(ILogger logger, string key, ulong major, int limit) + { + _logger = logger.ForContext(); + + Key = key; + Major = major; + + Limit = limit; + Remaining = limit; + _resetTimeValid = false; + } + + public string Key { get; } + public ulong Major { get; } + + public int Remaining { get; private set; } + + public int Limit { get; private set; } + + public DateTimeOffset LastUsed { get; private set; } = DateTimeOffset.UtcNow; + + public bool TryAcquire() + { + LastUsed = DateTimeOffset.Now; + + try + { + _semaphore.Wait(); + + if (Remaining > 0) + { + _logger.Debug( + "{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit} left], allowing through", + Key, Major, Remaining, Limit); + Remaining--; + return true; + } + + _logger.Debug("{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit}] left, denying", + Key, Major, Remaining, Limit); + return false; + } + finally + { + _semaphore.Release(); + } + } + + public void HandleResponse(RatelimitHeaders headers) + { + try + { + _semaphore.Wait(); + + if (headers.ResetAfter != null) + { + var headerNextReset = DateTimeOffset.UtcNow + headers.ResetAfter.Value; // todo: server time + if (headerNextReset > _nextReset) + { + _logger.Debug("{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server", + Key, Major, _nextReset); + + _nextReset = headerNextReset; + _resetTimeValid = true; + } + } + + if (headers.Limit != null) + Limit = headers.Limit.Value; + } + finally + { + _semaphore.Release(); + } + } + + public void Tick(DateTimeOffset now) + { + try + { + _semaphore.Wait(); + + // If we're past the reset time *and* we haven't reset already, do that + var timeSinceReset = _nextReset - now; + 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; + + // If we have a really small (or negative) value, return a fallback delay too + if (delay < Epsilon) + return FallbackDelay; + + return delay; + } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/BucketManager.cs b/Myriad/Rest/Ratelimit/BucketManager.cs new file mode 100644 index 00000000..b5326903 --- /dev/null +++ b/Myriad/Rest/Ratelimit/BucketManager.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +using Serilog; + +namespace Myriad.Rest.Ratelimit +{ + public class BucketManager: IDisposable + { + private static readonly TimeSpan StaleBucketTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan PruneWorkerInterval = TimeSpan.FromMinutes(1); + private readonly ConcurrentDictionary<(string key, ulong major), Bucket> _buckets = new(); + + private readonly ConcurrentDictionary _endpointKeyMap = new(); + private readonly ConcurrentDictionary _knownKeyLimits = new(); + + private readonly ILogger _logger; + + private readonly Task _worker; + private readonly CancellationTokenSource _workerCts = new(); + + public BucketManager(ILogger logger) + { + _logger = logger.ForContext(); + _worker = PruneWorker(_workerCts.Token); + } + + public void Dispose() + { + _workerCts.Dispose(); + _worker.Dispose(); + } + + public Bucket? GetBucket(string endpoint, ulong major) + { + if (!_endpointKeyMap.TryGetValue(endpoint, out var key)) + return null; + + if (_buckets.TryGetValue((key, major), out var bucket)) + return bucket; + + if (!_knownKeyLimits.TryGetValue(key, out var knownLimit)) + return null; + + 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) + { + _logger.Debug("Pruning unused bucket {Bucket} (last used at {BucketLastUsed})", bucket, + bucket.LastUsed); + _buckets.TryRemove(key, out _); + } + } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs b/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs new file mode 100644 index 00000000..9c9e2d00 --- /dev/null +++ b/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs @@ -0,0 +1,46 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Polly; + +namespace Myriad.Rest.Ratelimit +{ + public class DiscordRateLimitPolicy: AsyncPolicy + { + public const string EndpointContextKey = "Endpoint"; + public const string MajorContextKey = "Major"; + + private readonly Ratelimiter _ratelimiter; + + public DiscordRateLimitPolicy(Ratelimiter ratelimiter, PolicyBuilder? policyBuilder = null) + : base(policyBuilder) + { + _ratelimiter = ratelimiter; + } + + protected override async Task ImplementationAsync( + Func> action, Context context, CancellationToken ct, + bool continueOnCapturedContext) + { + if (!context.TryGetValue(EndpointContextKey, out var endpointObj) || !(endpointObj is string endpoint)) + throw new ArgumentException("Must provide endpoint in Polly context"); + + if (!context.TryGetValue(MajorContextKey, out var majorObj) || !(majorObj is ulong major)) + throw new ArgumentException("Must provide major in Polly context"); + + // Check rate limit, throw if we're not allowed... + _ratelimiter.AllowRequestOrThrow(endpoint, major, DateTimeOffset.Now); + + // We're OK, push it through + var response = await action(context, ct).ConfigureAwait(continueOnCapturedContext); + + // Update rate limit state with headers + var headers = new RatelimitHeaders(response); + _ratelimiter.HandleResponse(headers, endpoint, major); + + return response; + } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/RatelimitHeaders.cs b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs new file mode 100644 index 00000000..4a867deb --- /dev/null +++ b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using System.Net.Http; + +namespace Myriad.Rest.Ratelimit +{ + public record RatelimitHeaders + { + public RatelimitHeaders() { } + + public RatelimitHeaders(HttpResponseMessage response) + { + ServerDate = response.Headers.Date; + + if (response.Headers.TryGetValues("X-RateLimit-Limit", out var limit)) + Limit = int.Parse(limit!.First()); + + if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining)) + Remaining = int.Parse(remaining!.First()); + + if (response.Headers.TryGetValues("X-RateLimit-Reset", out var reset)) + Reset = DateTimeOffset.FromUnixTimeMilliseconds((long) (double.Parse(reset!.First()) * 1000)); + + if (response.Headers.TryGetValues("X-RateLimit-Reset-After", out var resetAfter)) + ResetAfter = TimeSpan.FromSeconds(double.Parse(resetAfter!.First())); + + if (response.Headers.TryGetValues("X-RateLimit-Bucket", out var bucket)) + Bucket = bucket.First(); + + if (response.Headers.TryGetValues("X-RateLimit-Global", out var global)) + Global = bool.Parse(global!.First()); + } + + public bool Global { get; init; } + public int? Limit { get; init; } + public int? Remaining { get; init; } + public DateTimeOffset? Reset { get; init; } + public TimeSpan? ResetAfter { get; init; } + public string? Bucket { get; init; } + + public DateTimeOffset? ServerDate { get; init; } + + public bool HasRatelimitInfo => + Limit != null && Remaining != null && Reset != null && ResetAfter != null && Bucket != null; + } +} \ No newline at end of file diff --git a/Myriad/Rest/Ratelimit/Ratelimiter.cs b/Myriad/Rest/Ratelimit/Ratelimiter.cs new file mode 100644 index 00000000..089656f1 --- /dev/null +++ b/Myriad/Rest/Ratelimit/Ratelimiter.cs @@ -0,0 +1,86 @@ +using System; + +using Myriad.Rest.Exceptions; + +using Serilog; + +namespace Myriad.Rest.Ratelimit +{ + public class Ratelimiter: IDisposable + { + private readonly BucketManager _buckets; + private readonly ILogger _logger; + + private DateTimeOffset? _globalRateLimitExpiry; + + public Ratelimiter(ILogger logger) + { + _logger = logger.ForContext(); + _buckets = new BucketManager(logger); + } + + public void Dispose() + { + _buckets.Dispose(); + } + + public void AllowRequestOrThrow(string endpoint, ulong major, DateTimeOffset now) + { + if (IsGloballyRateLimited(now)) + { + _logger.Warning("Globally rate limited until {GlobalRateLimitExpiry}, cancelling request", + _globalRateLimitExpiry); + throw new GloballyRatelimitedException(); + } + + var bucket = _buckets.GetBucket(endpoint, major); + if (bucket == null) + { + // No rate limit for this endpoint (yet), allow through + _logger.Debug("No rate limit data for endpoint {Endpoint}, allowing through", endpoint); + return; + } + + bucket.Tick(now); + + if (bucket.TryAcquire()) + // We're allowed to send it! :) + return; + + // We can't send this request right now; retrying... + var waitTime = bucket.GetResetDelay(now); + + // add a small buffer for Timing:tm: + waitTime += TimeSpan.FromMilliseconds(50); + + // (this is caught by a WaitAndRetry Polly handler, if configured) + throw new RatelimitBucketExhaustedException(bucket, waitTime); + } + + public void HandleResponse(RatelimitHeaders headers, string endpoint, ulong major) + { + if (!headers.HasRatelimitInfo) + return; + + // TODO: properly calculate server time? + if (headers.Global) + { + _logger.Warning( + "Global rate limit hit, resetting at {GlobalRateLimitExpiry} (in {GlobalRateLimitResetAfter}!", + _globalRateLimitExpiry, headers.ResetAfter); + _globalRateLimitExpiry = headers.Reset; + } + else + { + // Update buckets first, then get it again, to properly "transfer" this info over to the new value + _buckets.UpdateEndpointInfo(endpoint, headers.Bucket!, headers.Limit); + + var bucket = _buckets.GetBucket(endpoint, major); + bucket?.HandleResponse(headers); + } + } + + private bool IsGloballyRateLimited(DateTimeOffset now) => + _globalRateLimitExpiry > now; + } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/AllowedMentions.cs b/Myriad/Rest/Types/AllowedMentions.cs new file mode 100644 index 00000000..019c735d --- /dev/null +++ b/Myriad/Rest/Types/AllowedMentions.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Myriad.Rest.Types +{ + public record AllowedMentions + { + public enum ParseType + { + Roles, + Users, + Everyone + } + + public List? Parse { get; set; } + public List? Users { get; set; } + public List? Roles { get; set; } + public bool RepliedUser { get; set; } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/MultipartFile.cs b/Myriad/Rest/Types/MultipartFile.cs new file mode 100644 index 00000000..e5a488d6 --- /dev/null +++ b/Myriad/Rest/Types/MultipartFile.cs @@ -0,0 +1,6 @@ +using System.IO; + +namespace Myriad.Rest.Types +{ + public record MultipartFile(string Filename, Stream Data); +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/CommandRequest.cs b/Myriad/Rest/Types/Requests/CommandRequest.cs new file mode 100644 index 00000000..3958f44b --- /dev/null +++ b/Myriad/Rest/Types/Requests/CommandRequest.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +using Myriad.Types; + +namespace Myriad.Rest.Types +{ + public record ApplicationCommandRequest + { + public string Name { get; init; } + public string Description { get; init; } + public List? Options { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/CreateWebhookRequest.cs b/Myriad/Rest/Types/Requests/CreateWebhookRequest.cs new file mode 100644 index 00000000..cd38f67d --- /dev/null +++ b/Myriad/Rest/Types/Requests/CreateWebhookRequest.cs @@ -0,0 +1,4 @@ +namespace Myriad.Rest.Types.Requests +{ + public record CreateWebhookRequest(string Name); +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs b/Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs new file mode 100644 index 00000000..dcd19a35 --- /dev/null +++ b/Myriad/Rest/Types/Requests/ExecuteWebhookRequest.cs @@ -0,0 +1,13 @@ +using Myriad.Types; + +namespace Myriad.Rest.Types.Requests +{ + public record ExecuteWebhookRequest + { + public string? Content { get; init; } + public string? Username { get; init; } + public string? AvatarUrl { get; init; } + public Embed[] Embeds { get; init; } + public AllowedMentions? AllowedMentions { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/MessageEditRequest.cs b/Myriad/Rest/Types/Requests/MessageEditRequest.cs new file mode 100644 index 00000000..1fe03193 --- /dev/null +++ b/Myriad/Rest/Types/Requests/MessageEditRequest.cs @@ -0,0 +1,10 @@ +using Myriad.Types; + +namespace Myriad.Rest.Types.Requests +{ + public record MessageEditRequest + { + public string? Content { get; set; } + public Embed? Embed { get; set; } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/MessageRequest.cs b/Myriad/Rest/Types/Requests/MessageRequest.cs new file mode 100644 index 00000000..ae9625f7 --- /dev/null +++ b/Myriad/Rest/Types/Requests/MessageRequest.cs @@ -0,0 +1,13 @@ +using Myriad.Types; + +namespace Myriad.Rest.Types.Requests +{ + public record MessageRequest + { + public string? Content { get; set; } + public object? Nonce { get; set; } + public bool Tts { get; set; } + public AllowedMentions AllowedMentions { get; set; } + public Embed? Embeds { get; set; } + } +} \ No newline at end of file diff --git a/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs b/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs new file mode 100644 index 00000000..6fcd8fc0 --- /dev/null +++ b/Myriad/Rest/Types/Requests/ModifyGuildMemberRequest.cs @@ -0,0 +1,7 @@ +namespace Myriad.Rest.Types +{ + public record ModifyGuildMemberRequest + { + public string? Nick { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Serialization/JsonSerializerOptionsExtensions.cs b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs new file mode 100644 index 00000000..b72bec2e --- /dev/null +++ b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Myriad.Serialization +{ + public static class JsonSerializerOptionsExtensions + { + public static JsonSerializerOptions ConfigureForNewcord(this JsonSerializerOptions opts) + { + opts.PropertyNamingPolicy = new JsonSnakeCaseNamingPolicy(); + opts.NumberHandling = JsonNumberHandling.AllowReadingFromString; + opts.IncludeFields = true; + + opts.Converters.Add(new PermissionSetJsonConverter()); + opts.Converters.Add(new ShardInfoJsonConverter()); + + return opts; + } + } +} \ No newline at end of file diff --git a/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs b/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs new file mode 100644 index 00000000..4a09d8f0 --- /dev/null +++ b/Myriad/Serialization/JsonSnakeCaseNamingPolicy.cs @@ -0,0 +1,88 @@ +using System; +using System.Text; +using System.Text.Json; + +namespace Myriad.Serialization +{ + // From https://github.com/J0rgeSerran0/JsonNamingPolicy/blob/master/JsonSnakeCaseNamingPolicy.cs, no NuGet :/ + public class JsonSnakeCaseNamingPolicy: JsonNamingPolicy + { + private readonly string _separator = "_"; + + public override string ConvertName(string name) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(name)) return string.Empty; + + ReadOnlySpan spanName = name.Trim(); + + var stringBuilder = new StringBuilder(); + var addCharacter = true; + + var isPreviousSpace = false; + var isPreviousSeparator = false; + var isCurrentSpace = false; + var isNextLower = false; + var isNextUpper = false; + var isNextSpace = false; + + for (var position = 0; position < spanName.Length; position++) + { + if (position != 0) + { + isCurrentSpace = spanName[position] == 32; + isPreviousSpace = spanName[position - 1] == 32; + isPreviousSeparator = spanName[position - 1] == 95; + + if (position + 1 != spanName.Length) + { + isNextLower = spanName[position + 1] > 96 && spanName[position + 1] < 123; + isNextUpper = spanName[position + 1] > 64 && spanName[position + 1] < 91; + isNextSpace = spanName[position + 1] == 32; + } + + if (isCurrentSpace && + (isPreviousSpace || + isPreviousSeparator || + isNextUpper || + isNextSpace)) + { + addCharacter = false; + } + else + { + var isCurrentUpper = spanName[position] > 64 && spanName[position] < 91; + var isPreviousLower = spanName[position - 1] > 96 && spanName[position - 1] < 123; + var isPreviousNumber = spanName[position - 1] > 47 && spanName[position - 1] < 58; + + if (isCurrentUpper && + (isPreviousLower || + isPreviousNumber || + isNextLower || + isNextSpace || + isNextLower && !isPreviousSpace)) + { + stringBuilder.Append(_separator); + } + else + { + if (isCurrentSpace && + !isPreviousSpace && + !isNextSpace) + { + stringBuilder.Append(_separator); + addCharacter = false; + } + } + } + } + + if (addCharacter) + stringBuilder.Append(spanName[position]); + else + addCharacter = true; + } + + return stringBuilder.ToString().ToLower(); + } + } +} \ No newline at end of file diff --git a/Myriad/Serialization/JsonStringConverter.cs b/Myriad/Serialization/JsonStringConverter.cs new file mode 100644 index 00000000..975da967 --- /dev/null +++ b/Myriad/Serialization/JsonStringConverter.cs @@ -0,0 +1,22 @@ +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; + } + + 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/PermissionSetJsonConverter.cs b/Myriad/Serialization/PermissionSetJsonConverter.cs new file mode 100644 index 00000000..02fc313b --- /dev/null +++ b/Myriad/Serialization/PermissionSetJsonConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Myriad.Types; + +namespace Myriad.Serialization +{ + public class PermissionSetJsonConverter: JsonConverter + { + public override PermissionSet Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = reader.GetString(); + if (str == null) return default; + + return (PermissionSet) ulong.Parse(str); + } + + public override void Write(Utf8JsonWriter writer, PermissionSet value, JsonSerializerOptions options) + { + writer.WriteStringValue(((ulong) value).ToString()); + } + } +} \ No newline at end of file diff --git a/Myriad/Serialization/ShardInfoJsonConverter.cs b/Myriad/Serialization/ShardInfoJsonConverter.cs new file mode 100644 index 00000000..a504d1b3 --- /dev/null +++ b/Myriad/Serialization/ShardInfoJsonConverter.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Myriad.Gateway; + +namespace Myriad.Serialization +{ + public class ShardInfoJsonConverter: JsonConverter + { + public override ShardInfo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var arr = JsonSerializer.Deserialize(ref reader); + if (arr?.Length != 2) + throw new JsonException("Expected shard info as array of length 2"); + + return new ShardInfo(arr[0], arr[1]); + } + + public override void Write(Utf8JsonWriter writer, ShardInfo value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteNumberValue(value.ShardId); + writer.WriteNumberValue(value.NumShards); + writer.WriteEndArray(); + } + } +} \ No newline at end of file diff --git a/Myriad/Types/Activity.cs b/Myriad/Types/Activity.cs new file mode 100644 index 00000000..261a6c66 --- /dev/null +++ b/Myriad/Types/Activity.cs @@ -0,0 +1,22 @@ +namespace Myriad.Types +{ + public record Activity: ActivityPartial + { + } + + public record ActivityPartial + { + public string Name { get; init; } + public ActivityType Type { get; init; } + public string? Url { get; init; } + } + + public enum ActivityType + { + Game = 0, + Streaming = 1, + Listening = 2, + Custom = 4, + Competing = 5 + } +} \ No newline at end of file diff --git a/Myriad/Types/Application/Application.cs b/Myriad/Types/Application/Application.cs new file mode 100644 index 00000000..1fe04127 --- /dev/null +++ b/Myriad/Types/Application/Application.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace Myriad.Types +{ + public record Application: ApplicationPartial + { + public string Name { get; init; } + public string? Icon { get; init; } + public string Description { get; init; } + public string[]? RpcOrigins { get; init; } + public bool BotPublic { get; init; } + public bool BotRequireCodeGrant { get; init; } + public User Owner { get; init; } // TODO: docs specify this is "partial", what does that mean + public string Summary { get; init; } + public string VerifyKey { get; init; } + public ulong? GuildId { get; init; } + public ulong? PrimarySkuId { get; init; } + public string? Slug { get; init; } + public string? CoverImage { get; init; } + } + + public record ApplicationPartial + { + public ulong Id { get; init; } + public int Flags { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Application/ApplicationCommand.cs b/Myriad/Types/Application/ApplicationCommand.cs new file mode 100644 index 00000000..92ecd856 --- /dev/null +++ b/Myriad/Types/Application/ApplicationCommand.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Myriad.Types +{ + public record ApplicationCommand + { + public ulong Id { get; init; } + public ulong ApplicationId { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public ApplicationCommandOption[]? Options { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Application/ApplicationCommandInteractionData.cs b/Myriad/Types/Application/ApplicationCommandInteractionData.cs new file mode 100644 index 00000000..3c4543a3 --- /dev/null +++ b/Myriad/Types/Application/ApplicationCommandInteractionData.cs @@ -0,0 +1,9 @@ +namespace Myriad.Types +{ + public record ApplicationCommandInteractionData + { + public ulong Id { get; init; } + public string Name { get; init; } + public ApplicationCommandInteractionDataOption[] Options { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs b/Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs new file mode 100644 index 00000000..0f5a2730 --- /dev/null +++ b/Myriad/Types/Application/ApplicationCommandInteractionDataOption.cs @@ -0,0 +1,9 @@ +namespace Myriad.Types +{ + public record ApplicationCommandInteractionDataOption + { + public string Name { get; init; } + public object? Value { get; init; } + public ApplicationCommandInteractionDataOption[]? Options { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Application/ApplicationCommandOption.cs b/Myriad/Types/Application/ApplicationCommandOption.cs new file mode 100644 index 00000000..09ba945f --- /dev/null +++ b/Myriad/Types/Application/ApplicationCommandOption.cs @@ -0,0 +1,24 @@ +namespace Myriad.Types +{ + public record ApplicationCommandOption(ApplicationCommandOption.OptionType Type, string Name, string Description) + { + public enum OptionType + { + Subcommand = 1, + SubcommandGroup = 2, + String = 3, + Integer = 4, + Boolean = 5, + User = 6, + Channel = 7, + Role = 8 + } + + public bool Default { get; init; } + public bool Required { get; init; } + public Choice[]? Choices { get; init; } + public ApplicationCommandOption[]? Options { get; init; } + + public record Choice(string Name, object Value); + } +} \ No newline at end of file diff --git a/Myriad/Types/Application/Interaction.cs b/Myriad/Types/Application/Interaction.cs new file mode 100644 index 00000000..cc269f3a --- /dev/null +++ b/Myriad/Types/Application/Interaction.cs @@ -0,0 +1,19 @@ +namespace Myriad.Types +{ + public record Interaction + { + public enum InteractionType + { + Ping = 1, + ApplicationCommand = 2 + } + + 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 string Token { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs b/Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs new file mode 100644 index 00000000..2718aa0e --- /dev/null +++ b/Myriad/Types/Application/InteractionApplicationCommandCallbackData.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +using Myriad.Rest.Types; + +namespace Myriad.Types +{ + public record InteractionApplicationCommandCallbackData + { + public bool? Tts { get; init; } + public string Content { get; init; } + public Embed[]? Embeds { get; init; } + public AllowedMentions? AllowedMentions { get; init; } + public Message.MessageFlags Flags { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Application/InteractionResponse.cs b/Myriad/Types/Application/InteractionResponse.cs new file mode 100644 index 00000000..12e1259d --- /dev/null +++ b/Myriad/Types/Application/InteractionResponse.cs @@ -0,0 +1,17 @@ +namespace Myriad.Types +{ + public record InteractionResponse + { + public enum ResponseType + { + Pong = 1, + Acknowledge = 2, + ChannelMessage = 3, + ChannelMessageWithSource = 4, + AckWithSource = 5 + } + + public ResponseType Type { get; init; } + public InteractionApplicationCommandCallbackData? Data { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Channel.cs b/Myriad/Types/Channel.cs new file mode 100644 index 00000000..72e1854c --- /dev/null +++ b/Myriad/Types/Channel.cs @@ -0,0 +1,40 @@ +namespace Myriad.Types +{ + public record Channel + { + public enum ChannelType + { + GuildText = 0, + Dm = 1, + GuildVoice = 2, + GroupDm = 3, + GuildCategory = 4, + GuildNews = 5, + GuildStore = 6 + } + + 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 long? ParentId { get; init; } + public Overwrite[]? PermissionOverwrites { get; init; } + + 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/Embed.cs b/Myriad/Types/Embed.cs new file mode 100644 index 00000000..46560cb8 --- /dev/null +++ b/Myriad/Types/Embed.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; + +namespace Myriad.Types +{ + public record Embed + { + public string? Title { get; init; } + public string? Type { get; init; } + public string? Description { get; init; } + public string? Url { get; init; } + public string? Timestamp { get; init; } + public uint? Color { get; init; } + public EmbedFooter? Footer { get; init; } + public EmbedImage? Image { get; init; } + public EmbedThumbnail? Thumbnail { get; init; } + public EmbedVideo? Video { get; init; } + public EmbedProvider? Provider { get; init; } + public EmbedAuthor? Author { get; init; } + public Field[]? Fields { get; init; } + + public record EmbedFooter ( + string Text, + string? IconUrl = null, + string? ProxyIconUrl = null + ); + + public record EmbedImage ( + string? Url, + uint? Width = null, + uint? Height = null + ); + + public record EmbedThumbnail ( + string? Url, + string? ProxyUrl = null, + uint? Width = null, + uint? Height = null + ); + + public record EmbedVideo ( + string? Url, + uint? Width = null, + uint? Height = null + ); + + public record EmbedProvider ( + string? Name, + string? Url + ); + + public record EmbedAuthor ( + string? Name = null, + string? Url = null, + string? IconUrl = null, + string? ProxyIconUrl = null + ); + + public record Field ( + string Name, + string Value, + bool Inline = false + ); + } +} \ No newline at end of file diff --git a/Myriad/Types/Emoji.cs b/Myriad/Types/Emoji.cs new file mode 100644 index 00000000..415d42a1 --- /dev/null +++ b/Myriad/Types/Emoji.cs @@ -0,0 +1,9 @@ +namespace Myriad.Types +{ + public record Emoji + { + public ulong? Id { get; init; } + public string? Name { get; init; } + public bool? Animated { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Gateway/GatewayInfo.cs b/Myriad/Types/Gateway/GatewayInfo.cs new file mode 100644 index 00000000..055681cb --- /dev/null +++ b/Myriad/Types/Gateway/GatewayInfo.cs @@ -0,0 +1,13 @@ +namespace Myriad.Types +{ + public record GatewayInfo + { + public string Url { get; init; } + + public record Bot: GatewayInfo + { + public int Shards { get; init; } + public SessionStartLimit SessionStartLimit { get; init; } + } + } +} \ No newline at end of file diff --git a/Myriad/Types/Gateway/SessionStartLimit.cs b/Myriad/Types/Gateway/SessionStartLimit.cs new file mode 100644 index 00000000..381c7cd9 --- /dev/null +++ b/Myriad/Types/Gateway/SessionStartLimit.cs @@ -0,0 +1,9 @@ +namespace Myriad.Types +{ + public record SessionStartLimit + { + public int Total { get; init; } + public int Remaining { get; init; } + public int ResetAfter { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Guild.cs b/Myriad/Types/Guild.cs new file mode 100644 index 00000000..9b9cccfe --- /dev/null +++ b/Myriad/Types/Guild.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Myriad.Types +{ + 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 bool? WidgetChannelId { get; init; } + public int VerificationLevel { get; init; } + + public Role[] Roles { get; init; } + public string[] Features { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/GuildMember.cs b/Myriad/Types/GuildMember.cs new file mode 100644 index 00000000..da25fd65 --- /dev/null +++ b/Myriad/Types/GuildMember.cs @@ -0,0 +1,14 @@ +namespace Myriad.Types +{ + public record GuildMember: GuildMemberPartial + { + public User User { get; init; } + } + + public record GuildMemberPartial + { + public string Nick { get; init; } + public ulong[] Roles { get; init; } + public string JoinedAt { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/Message.cs b/Myriad/Types/Message.cs new file mode 100644 index 00000000..c74f67bf --- /dev/null +++ b/Myriad/Types/Message.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Net.Mail; + +namespace Myriad.Types +{ + public record Message + { + [Flags] + public enum MessageFlags + { + Crossposted = 1 << 0, + IsCrosspost = 1 << 1, + SuppressEmbeds = 1 << 2, + SourceMessageDeleted = 1 << 3, + Urgent = 1 << 4, + Ephemeral = 1 << 6 + } + + public enum MessageType + { + Default = 0, + RecipientAdd = 1, + RecipientRemove = 2, + Call = 3, + ChannelNameChange = 4, + ChannelIconChange = 5, + ChannelPinnedMessage = 6, + GuildMemberJoin = 7, + UserPremiumGuildSubscription = 8, + UserPremiumGuildSubscriptionTier1 = 9, + UserPremiumGuildSubscriptionTier2 = 10, + UserPremiumGuildSubscriptionTier3 = 11, + ChannelFollowAdd = 12, + GuildDiscoveryDisqualified = 14, + GuildDiscoveryRequalified = 15, + Reply = 19, + ApplicationCommand = 20 + } + + 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; } + + // todo: null vs. absence + public Message? ReferencedMessage { get; init; } + + public record Reference(ulong? GuildId, ulong? ChannelId, ulong? MessageId); + + public record Attachment + { + public ulong Id { get; init; } + public string Filename { get; init; } + public int Size { get; init; } + public string Url { get; init; } + public string ProxyUrl { get; init; } + public int? Width { get; init; } + public int? Height { get; init; } + } + + public record Reaction + { + public int Count { get; init; } + public bool Me { get; init; } + public Emoji Emoji { get; init; } + } + } +} \ No newline at end of file diff --git a/Myriad/Types/PermissionSet.cs b/Myriad/Types/PermissionSet.cs new file mode 100644 index 00000000..ce4c3a06 --- /dev/null +++ b/Myriad/Types/PermissionSet.cs @@ -0,0 +1,47 @@ +using System; + +namespace Myriad.Types +{ + [Flags] + public enum PermissionSet: ulong + { + CreateInvite = 0x1, + KickMembers = 0x2, + BanMembers = 0x4, + Administrator = 0x8, + ManageChannels = 0x10, + ManageGuild = 0x20, + AddReactions = 0x40, + ViewAuditLog = 0x80, + PrioritySpeaker = 0x100, + Stream = 0x200, + ViewChannel = 0x400, + SendMessages = 0x800, + SendTtsMessages = 0x1000, + ManageMessages = 0x2000, + EmbedLinks = 0x4000, + AttachFiles = 0x8000, + ReadMessageHistory = 0x10000, + MentionEveryone = 0x20000, + UseExternalEmojis = 0x40000, + ViewGuildInsights = 0x80000, + Connect = 0x100000, + Speak = 0x200000, + MuteMembers = 0x400000, + DeafenMembers = 0x800000, + MoveMembers = 0x1000000, + UseVad = 0x2000000, + ChangeNickname = 0x4000000, + ManageNicknames = 0x8000000, + ManageRoles = 0x10000000, + ManageWebhooks = 0x20000000, + ManageEmojis = 0x40000000, + + // Special: + None = 0, + All = 0x7FFFFFFF, + + Dm = ViewChannel | SendMessages | ReadMessageHistory | AddReactions | AttachFiles | EmbedLinks | + UseExternalEmojis | Connect | Speak | UseVad + } +} \ No newline at end of file diff --git a/Myriad/Types/Permissions.cs b/Myriad/Types/Permissions.cs new file mode 100644 index 00000000..423b7ae8 --- /dev/null +++ b/Myriad/Types/Permissions.cs @@ -0,0 +1,6 @@ +namespace Myriad.Types +{ + public static class Permissions + { + } +} \ No newline at end of file diff --git a/Myriad/Types/Role.cs b/Myriad/Types/Role.cs new file mode 100644 index 00000000..2e77f2ac --- /dev/null +++ b/Myriad/Types/Role.cs @@ -0,0 +1,14 @@ +namespace Myriad.Types +{ + public record Role + { + public ulong Id { get; init; } + public string Name { get; init; } + public uint Color { get; init; } + public bool Hoist { get; init; } + public int Position { get; init; } + public PermissionSet Permissions { get; init; } + public bool Managed { get; init; } + public bool Mentionable { get; init; } + } +} \ No newline at end of file diff --git a/Myriad/Types/User.cs b/Myriad/Types/User.cs new file mode 100644 index 00000000..ad6a4795 --- /dev/null +++ b/Myriad/Types/User.cs @@ -0,0 +1,38 @@ +using System; + +namespace Myriad.Types +{ + public record User + { + [Flags] + public enum Flags + { + DiscordEmployee = 1 << 0, + PartneredServerOwner = 1 << 1, + HypeSquadEvents = 1 << 2, + BugHunterLevel1 = 1 << 3, + HouseBravery = 1 << 6, + HouseBrilliance = 1 << 7, + HouseBalance = 1 << 8, + EarlySupporter = 1 << 9, + TeamUser = 1 << 10, + System = 1 << 12, + BugHunterLevel2 = 1 << 14, + VerifiedBot = 1 << 16, + EarlyVerifiedBotDeveloper = 1 << 17 + } + + public ulong Id { get; init; } + public string Username { get; init; } + public string Discriminator { get; init; } + public string? Avatar { get; init; } + public bool Bot { get; init; } + public bool? System { get; init; } + public Flags PublicFlags { get; init; } + + public record Extra: User + { + public GuildMemberPartial? Member { get; init; } + } + } +} \ No newline at end of file diff --git a/Myriad/Types/Webhook.cs b/Myriad/Types/Webhook.cs new file mode 100644 index 00000000..d5ac3c03 --- /dev/null +++ b/Myriad/Types/Webhook.cs @@ -0,0 +1,21 @@ +namespace Myriad.Types +{ + public record Webhook + { + public ulong Id { get; init; } + public WebhookType Type { get; init; } + public ulong? GuildId { get; init; } + public ulong ChannelId { get; init; } + public User? User { get; init; } + public string? Name { get; init; } + public string? Avatar { get; init; } + public string? Token { get; init; } + public ulong? ApplicationId { get; init; } + } + + public enum WebhookType + { + Incoming = 1, + ChannelFollower = 2 + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index f60ca6c9..b7914d91 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.WebSockets; @@ -9,10 +10,10 @@ using App.Metrics; using Autofac; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; -using DSharpPlus.Exceptions; +using Myriad.Cache; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Types; using NodaTime; @@ -27,47 +28,38 @@ namespace PluralKit.Bot { public class Bot { - private readonly DiscordShardedClient _client; + private readonly ConcurrentDictionary _guildMembers = new(); + + 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 ErrorMessageService _errorMessageService; private readonly CommandMessageService _commandMessageService; + private readonly IDiscordCache _cache; private bool _hasReceivedReady = false; private Timer _periodicTask; // Never read, just kept here for GC reasons - public Bot(DiscordShardedClient client, ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics, - ErrorMessageService errorMessageService, CommandMessageService commandMessageService) + public Bot(ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics, + ErrorMessageService errorMessageService, CommandMessageService commandMessageService, Cluster cluster, DiscordApiClient rest, IDiscordCache cache) { - _client = client; _logger = logger.ForContext(); _services = services; _collector = collector; _metrics = metrics; _errorMessageService = errorMessageService; _commandMessageService = commandMessageService; + _cluster = cluster; + _rest = rest; + _cache = cache; } public void Init() { - // HandleEvent takes a type parameter, automatically inferred by the event type - // It will then look up an IEventHandler in the DI container and call that object's handler method - // For registering new ones, see Modules.cs - _client.MessageCreated += HandleEvent; - _client.MessageDeleted += HandleEvent; - _client.MessageUpdated += HandleEvent; - _client.MessagesBulkDeleted += HandleEvent; - _client.MessageReactionAdded += HandleEvent; - - // Update shard status for shards immediately on connect - _client.Ready += (client, _) => - { - _hasReceivedReady = true; - return UpdateBotStatus(client); - }; - _client.Resumed += (client, _) => UpdateBotStatus(client); + _cluster.EventReceived += OnEventReceived; // Init the shard stuff _services.Resolve().Init(); @@ -83,6 +75,58 @@ namespace PluralKit.Bot }, null, timeTillNextWholeMinute, TimeSpan.FromMinutes(1)); } + public GuildMemberPartial? BotMemberIn(ulong guildId) => _guildMembers.GetValueOrDefault(guildId); + + private async Task OnEventReceived(Shard shard, IGatewayEvent evt) + { + await _cache.HandleGatewayEvent(evt); + + TryUpdateSelfMember(shard, evt); + + // HandleEvent takes a type parameter, automatically inferred by the event type + // It will then look up an IEventHandler in the DI container and call that object's handler method + // For registering new ones, see Modules.cs + if (evt is MessageCreateEvent mc) + await HandleEvent(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); + + // 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 void TryUpdateSelfMember(Shard shard, IGatewayEvent evt) + { + if (evt is GuildCreateEvent gc) + _guildMembers[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) + _guildMembers[mc.GuildId!.Value] = mc.Member; + if (evt is GuildMemberAddEvent gma && gma.User.Id == shard.User?.Id) + _guildMembers[gma.GuildId] = gma; + if (evt is GuildMemberUpdateEvent gmu && gmu.User.Id == shard.User?.Id) + _guildMembers[gmu.GuildId] = gmu; + } + + 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 @@ -92,10 +136,24 @@ namespace PluralKit.Bot // We're not actually properly disconnecting from the gateway (lol) so it'll linger for a few minutes // Should be plenty of time for the bot to connect again next startup and set the real status if (_hasReceivedReady) - await _client.UpdateStatusAsync(new DiscordActivity("Restarting... (please wait)"), UserStatus.Idle); + { + await Task.WhenAll(_cluster.Shards.Values.Select(shard => + shard.UpdateStatus(new GatewayStatusUpdate + { + Activities = new[] + { + new ActivityPartial + { + Name = "Restarting... (please wait)", + Type = ActivityType.Game + } + }, + Status = GatewayStatusUpdate.UserStatus.Idle + }))); + } } - private Task HandleEvent(DiscordClient shard, T evt) where T: DiscordEventArgs + 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(); @@ -121,7 +179,7 @@ namespace PluralKit.Bot try { using var timer = _metrics.Measure.Timer.Time(BotMetrics.EventsHandled, - new MetricTags("event", typeof(T).Name.Replace("EventArgs", ""))); + 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 @@ -131,13 +189,13 @@ namespace PluralKit.Bot } catch (Exception exc) { - await HandleError(handler, evt, serviceScope, exc); + await HandleError(shard, handler, evt, serviceScope, exc); } } } - - private async Task HandleError(IEventHandler handler, T evt, ILifetimeScope serviceScope, Exception exc) - where T: DiscordEventArgs + + 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); @@ -149,7 +207,7 @@ namespace PluralKit.Bot .Error(exc, "Exception in event handler: {SentryEventId}", sentryEvent.EventId); // If the event is us responding to our own error messages, don't bother logging - if (evt is MessageCreateEventArgs mc && mc.Author.Id == _client.CurrentUser.Id) + if (evt is MessageCreateEvent mc && mc.Author.Id == shard.User?.Id) return; var shouldReport = exc.IsOurProblem(); @@ -160,19 +218,21 @@ namespace PluralKit.Bot var sentryScope = serviceScope.Resolve(); // Add some specific info about Discord error responses, as a breadcrumb - if (exc is BadRequestException bre) - sentryScope.AddBreadcrumb(bre.WebResponse.Response, "response.error", data: new Dictionary(bre.WebResponse.Headers)); - if (exc is NotFoundException nfe) - sentryScope.AddBreadcrumb(nfe.WebResponse.Response, "response.error", data: new Dictionary(nfe.WebResponse.Headers)); - if (exc is UnauthorizedException ue) - sentryScope.AddBreadcrumb(ue.WebResponse.Response, "response.error", data: new Dictionary(ue.WebResponse.Headers)); + // 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); // Once we've sent it to Sentry, report it to the user (if we have permission to) var reportChannel = handler.ErrorChannelFor(evt); - if (reportChannel != null && reportChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks)) - await _errorMessageService.SendErrorMessage(reportChannel, sentryEvent.EventId.ToString()); + // TODO: ID lookup + // if (reportChannel != null && reportChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks)) + // await _errorMessageService.SendErrorMessage(reportChannel, sentryEvent.EventId.ToString()); } } @@ -191,23 +251,38 @@ namespace PluralKit.Bot _logger.Debug("Submitted metrics to backend"); } - private async Task UpdateBotStatus(DiscordClient specificShard = null) + 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 = _client.ShardClients.Values.Sum(c => c.Guilds.Count); + + 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(DiscordClient shard) => - shard.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers | shard #{shard.ShardId}")); - + Task UpdateStatus(Shard shard) => + shard.UpdateStatus(new GatewayStatusUpdate + { + Activities = new[] + { + new ActivityPartial + { + Name = $"pk;help | in {totalGuilds} servers | shard #{shard.ShardInfo?.ShardId}", + Type = ActivityType.Game, + Url = "https://pluralkit.me/" + } + } + }); + if (specificShard != null) await UpdateStatus(specificShard); else // Run shard updates concurrently - await Task.WhenAll(_client.ShardClients.Values.Select(UpdateStatus)); + await Task.WhenAll(_cluster.Shards.Values.Select(UpdateStatus)); + } + catch (WebSocketException) + { + // TODO: this still thrown? } - catch (WebSocketException) { } } } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index effdfe46..ad96ecd5 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -9,8 +9,14 @@ using Autofac; using DSharpPlus; using DSharpPlus.Entities; +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Types; + using PluralKit.Core; +using Permissions = DSharpPlus.Permissions; + namespace PluralKit.Bot { public class Context @@ -19,10 +25,17 @@ namespace PluralKit.Bot private readonly DiscordRestClient _rest; private readonly DiscordShardedClient _client; - private readonly DiscordClient _shard; - private readonly DiscordMessage _message; + private readonly DiscordClient _shard = null; + private readonly Shard _shardNew; + private readonly Guild? _guild; + private readonly Channel _channel; + private readonly DiscordMessage _message = null; + private readonly Message _messageNew; private readonly Parameters _parameters; private readonly MessageContext _messageContext; + private readonly GuildMemberPartial? _botMember; + private readonly PermissionSet _botPermissions; + private readonly PermissionSet _userPermissions; private readonly IDatabase _db; private readonly ModelRepository _repo; @@ -32,31 +45,47 @@ namespace PluralKit.Bot private Command _currentCommand; - public Context(ILifetimeScope provider, DiscordClient shard, DiscordMessage message, int commandParseOffset, - PKSystem senderSystem, MessageContext messageContext) + public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, + PKSystem senderSystem, MessageContext messageContext, GuildMemberPartial? botMember) { _rest = provider.Resolve(); _client = provider.Resolve(); - _message = message; - _shard = shard; + _messageNew = message; + _shardNew = shard; + _guild = guild; + _channel = channel; _senderSystem = senderSystem; _messageContext = messageContext; + _botMember = botMember; _db = provider.Resolve(); _repo = provider.Resolve(); _metrics = provider.Resolve(); _provider = provider; _commandMessageService = provider.Resolve(); _parameters = new Parameters(message.Content.Substring(commandParseOffset)); + + _botPermissions = message.GuildId != null + ? PermissionExtensions.PermissionsFor(guild!, channel, shard.User?.Id ?? default, botMember!.Roles) + : PermissionSet.Dm; + _userPermissions = message.GuildId != null + ? PermissionExtensions.PermissionsFor(guild!, channel, message.Author.Id, message.Member!.Roles) + : PermissionSet.Dm; } public DiscordUser Author => _message.Author; public DiscordChannel Channel => _message.Channel; + public Channel ChannelNew => _channel; public DiscordMessage Message => _message; + public Message MessageNew => _messageNew; public DiscordGuild Guild => _message.Channel.Guild; + public Guild GuildNew => _guild; public DiscordClient Shard => _shard; public DiscordShardedClient Client => _client; public MessageContext MessageContext => _messageContext; + public PermissionSet BotPermissions => _botPermissions; + public PermissionSet UserPermissions => _userPermissions; + public DiscordRestClient Rest => _rest; public PKSystem System => _senderSystem; diff --git a/PluralKit.Bot/Handlers/IEventHandler.cs b/PluralKit.Bot/Handlers/IEventHandler.cs index 839eeba0..4a086706 100644 --- a/PluralKit.Bot/Handlers/IEventHandler.cs +++ b/PluralKit.Bot/Handlers/IEventHandler.cs @@ -1,15 +1,13 @@ using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; +using Myriad.Gateway; namespace PluralKit.Bot { - public interface IEventHandler where T: DiscordEventArgs + public interface IEventHandler where T: IGatewayEvent { - Task Handle(DiscordClient shard, T evt); + Task Handle(Shard shard, T evt); - DiscordChannel ErrorChannelFor(T evt) => null; + ulong? ErrorChannelFor(T evt) => null; } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index d51fe0fc..95cc9995 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -5,18 +5,22 @@ using App.Metrics; using Autofac; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Types.Requests; +using Myriad.Types; using PluralKit.Core; namespace PluralKit.Bot { - public class MessageCreated: IEventHandler + public class MessageCreated: IEventHandler { + private readonly Bot _bot; private readonly CommandTree _tree; - private readonly DiscordShardedClient _client; + private readonly IDiscordCache _cache; private readonly LastMessageCacheService _lastMessageCache; private readonly LoggerCleanService _loggerClean; private readonly IMetrics _metrics; @@ -25,73 +29,81 @@ namespace PluralKit.Bot private readonly IDatabase _db; private readonly ModelRepository _repo; private readonly BotConfig _config; + private readonly DiscordApiClient _rest; public MessageCreated(LastMessageCacheService lastMessageCache, LoggerCleanService loggerClean, - IMetrics metrics, ProxyService proxy, DiscordShardedClient client, - CommandTree tree, ILifetimeScope services, IDatabase db, BotConfig config, ModelRepository repo) + IMetrics metrics, ProxyService proxy, + CommandTree tree, ILifetimeScope services, IDatabase db, BotConfig config, ModelRepository repo, IDiscordCache cache, Bot bot, DiscordApiClient rest) { _lastMessageCache = lastMessageCache; _loggerClean = loggerClean; _metrics = metrics; _proxy = proxy; - _client = client; _tree = tree; _services = services; _db = db; _config = config; _repo = repo; + _cache = cache; + _bot = bot; + _rest = rest; } - public DiscordChannel ErrorChannelFor(MessageCreateEventArgs evt) => evt.Channel; + public ulong? ErrorChannelFor(MessageCreateEvent evt) => evt.ChannelId; - private bool IsDuplicateMessage(DiscordMessage evt) => + 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(evt.ChannelId) == evt.Id; + _lastMessageCache.GetLastMessage(msg.ChannelId) == msg.Id; - public async Task Handle(DiscordClient shard, MessageCreateEventArgs evt) + public async Task Handle(Shard shard, MessageCreateEvent evt) { - if (evt.Author?.Id == _client.CurrentUser?.Id) return; - if (evt.Message.MessageType != MessageType.Default) return; - if (IsDuplicateMessage(evt.Message)) return; + if (evt.Author.Id == shard.User?.Id) return; + if (evt.Type != Message.MessageType.Default) return; + if (IsDuplicateMessage(evt)) return; + + var guild = evt.GuildId != null ? await _cache.GetGuild(evt.GuildId.Value) : null; + var channel = await _cache.GetChannel(evt.ChannelId); // Log metrics and message info _metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived); - _lastMessageCache.AddMessage(evt.Channel.Id, evt.Message.Id); + _lastMessageCache.AddMessage(evt.ChannelId, evt.Id); // Get message context from DB (tracking w/ metrics) MessageContext ctx; await using (var conn = await _db.Obtain()) using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) - ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id); - + ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.GuildId ?? default, evt.ChannelId); + // Try each handler until we find one that succeeds if (await TryHandleLogClean(evt, ctx)) return; // Only do command/proxy handling if it's a user account - if (evt.Message.Author.IsBot || evt.Message.WebhookMessage || evt.Message.Author.IsSystem == true) + if (evt.Author.Bot || evt.WebhookId != null || evt.Author.System == true) return; - if (await TryHandleCommand(shard, evt, ctx)) + + if (await TryHandleCommand(shard, evt, guild, channel, ctx)) return; - await TryHandleProxy(shard, evt, ctx); + await TryHandleProxy(shard, evt, guild, channel, ctx); } - private async ValueTask TryHandleLogClean(MessageCreateEventArgs evt, MessageContext ctx) + private async ValueTask TryHandleLogClean(MessageCreateEvent evt, MessageContext ctx) { - if (!evt.Message.Author.IsBot || evt.Message.Channel.Type != ChannelType.Text || + var channel = await _cache.GetChannel(evt.ChannelId); + if (!evt.Author.Bot || channel!.Type != Channel.ChannelType.GuildText || !ctx.LogCleanupEnabled) return false; - await _loggerClean.HandleLoggerBotCleanup(evt.Message); + await _loggerClean.HandleLoggerBotCleanup(evt); return true; } - private async ValueTask TryHandleCommand(DiscordClient shard, MessageCreateEventArgs evt, MessageContext ctx) + private async ValueTask TryHandleCommand(Shard shard, MessageCreateEvent evt, Guild? guild, Channel channel, MessageContext ctx) { - var content = evt.Message.Content; + var content = evt.Content; if (content == null) return false; // Check for command prefix - if (!HasCommandPrefix(content, out var cmdStart)) + if (!HasCommandPrefix(content, shard.User?.Id ?? default, out var cmdStart)) return false; // Trim leading whitespace from command without actually modifying the string @@ -102,7 +114,7 @@ namespace PluralKit.Bot try { var system = ctx.SystemId != null ? await _db.Execute(c => _repo.GetSystem(c, ctx.SystemId.Value)) : null; - await _tree.ExecuteCommand(new Context(_services, shard, evt.Message, cmdStart, system, ctx)); + await _tree.ExecuteCommand(new Context(_services, shard, guild, channel, evt, cmdStart, system, ctx, _bot.BotMemberIn(channel.GuildId!.Value))); } catch (PKError) { @@ -113,7 +125,7 @@ namespace PluralKit.Bot return true; } - private bool HasCommandPrefix(string message, out int argPos) + private bool HasCommandPrefix(string message, ulong currentUserId, out int argPos) { // First, try prefixes defined in the config var prefixes = _config.Prefixes ?? BotConfig.DefaultPrefixes; @@ -128,23 +140,28 @@ namespace PluralKit.Bot // Then, check mention prefix (must be the bot user, ofc) argPos = -1; if (DiscordUtils.HasMentionPrefix(message, ref argPos, out var id)) - return id == _client.CurrentUser.Id; + return id == currentUserId; return false; } - private async ValueTask TryHandleProxy(DiscordClient shard, MessageCreateEventArgs evt, MessageContext ctx) + private async ValueTask TryHandleProxy(Shard shard, MessageCreateEvent evt, Guild guild, Channel channel, MessageContext ctx) { + var botMember = _bot.BotMemberIn(channel.GuildId!.Value); + var botPermissions = PermissionExtensions.PermissionsFor(guild, channel, shard.User!.Id, botMember!.Roles); + try { - return await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: ctx.AllowAutoproxy); + return await _proxy.HandleIncomingMessage(shard, evt, ctx, guild, channel, allowAutoproxy: ctx.AllowAutoproxy, botPermissions); } catch (PKError e) { // User-facing errors, print to the channel properly formatted - var msg = evt.Message; - if (msg.Channel.Guild == null || msg.Channel.BotHasAllPermissions(Permissions.SendMessages)) - await msg.Channel.SendMessageFixedAsync($"{Emojis.Error} {e.Message}"); + if (botPermissions.HasFlag(PermissionSet.SendMessages)) + { + await _rest.CreateMessage(evt.ChannelId, + new MessageRequest {Content = $"{Emojis.Error} {e.Message}"}); + } } return false; diff --git a/PluralKit.Bot/Handlers/MessageDeleted.cs b/PluralKit.Bot/Handlers/MessageDeleted.cs index f3a5cf70..3d2c236c 100644 --- a/PluralKit.Bot/Handlers/MessageDeleted.cs +++ b/PluralKit.Bot/Handlers/MessageDeleted.cs @@ -1,9 +1,7 @@ using System; -using System.Linq; using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.EventArgs; +using Myriad.Gateway; using PluralKit.Core; @@ -12,7 +10,7 @@ using Serilog; namespace PluralKit.Bot { // Double duty :) - public class MessageDeleted: IEventHandler, IEventHandler + public class MessageDeleted: IEventHandler, IEventHandler { private static readonly TimeSpan MessageDeleteDelay = TimeSpan.FromSeconds(15); @@ -27,7 +25,7 @@ namespace PluralKit.Bot _logger = logger.ForContext(); } - public Task Handle(DiscordClient shard, MessageDeleteEventArgs evt) + 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. @@ -35,7 +33,8 @@ namespace PluralKit.Bot async Task Inner() { await Task.Delay(MessageDeleteDelay); - await _db.Execute(c => _repo.DeleteMessage(c, evt.Message.Id)); + // TODO + // await _db.Execute(c => _repo.DeleteMessage(c, evt.Message.Id)); } // Fork a task to delete the message after a short delay @@ -44,14 +43,15 @@ namespace PluralKit.Bot return Task.CompletedTask; } - public Task Handle(DiscordClient shard, MessageBulkDeleteEventArgs evt) + 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.Messages.Count, evt.Channel.Id); - await _db.Execute(c => _repo.DeleteMessagesBulk(c, evt.Messages.Select(m => m.Id).ToList())); + // TODO + // _logger.Information("Bulk deleting {Count} messages in channel {Channel}", evt.Messages.Count, evt.Channel.Id); + // await _db.Execute(c => _repo.DeleteMessagesBulk(c, evt.Messages.Select(m => m.Id).ToList())); } _ = Inner(); diff --git a/PluralKit.Bot/Handlers/MessageEdited.cs b/PluralKit.Bot/Handlers/MessageEdited.cs index ac627ef0..a88e271f 100644 --- a/PluralKit.Bot/Handlers/MessageEdited.cs +++ b/PluralKit.Bot/Handlers/MessageEdited.cs @@ -3,14 +3,15 @@ using System.Threading.Tasks; using App.Metrics; using DSharpPlus; -using DSharpPlus.EventArgs; + +using Myriad.Gateway; using PluralKit.Core; namespace PluralKit.Bot { - public class MessageEdited: IEventHandler + public class MessageEdited: IEventHandler { private readonly LastMessageCacheService _lastMessageCache; private readonly ProxyService _proxy; @@ -29,22 +30,23 @@ namespace PluralKit.Bot _client = client; } - public async Task Handle(DiscordClient shard, MessageUpdateEventArgs evt) + public async Task Handle(Shard shard, MessageUpdateEvent evt) { - if (evt.Author?.Id == _client.CurrentUser?.Id) return; - - // Edit message events sometimes arrive with missing data; double-check it's all there - if (evt.Message.Content == null || evt.Author == null || evt.Channel.Guild == null) return; - - // Only react to the last message in the channel - if (_lastMessageCache.GetLastMessage(evt.Channel.Id) != evt.Message.Id) return; - - // Just run the normal message handling code, with a flag to disable autoproxying - MessageContext ctx; - await using (var conn = await _db.Obtain()) - using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) - ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id); - await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: false); + // TODO: fix + // if (evt.Author?.Id == _client.CurrentUser?.Id) return; + // + // // Edit message events sometimes arrive with missing data; double-check it's all there + // if (evt.Message.Content == null || evt.Author == null || evt.Channel.Guild == null) return; + // + // // Only react to the last message in the channel + // if (_lastMessageCache.GetLastMessage(evt.Channel.Id) != evt.Message.Id) return; + // + // // Just run the normal message handling code, with a flag to disable autoproxying + // MessageContext ctx; + // await using (var conn = await _db.Obtain()) + // using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) + // ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id); + // await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: false); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/ReactionAdded.cs b/PluralKit.Bot/Handlers/ReactionAdded.cs index b3f29ea4..6d2c2a15 100644 --- a/PluralKit.Bot/Handlers/ReactionAdded.cs +++ b/PluralKit.Bot/Handlers/ReactionAdded.cs @@ -5,13 +5,15 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using DSharpPlus.Exceptions; +using Myriad.Gateway; + using PluralKit.Core; using Serilog; namespace PluralKit.Bot { - public class ReactionAdded: IEventHandler + public class ReactionAdded: IEventHandler { private readonly IDatabase _db; private readonly ModelRepository _repo; @@ -28,9 +30,9 @@ namespace PluralKit.Bot _logger = logger.ForContext(); } - public async Task Handle(DiscordClient shard, MessageReactionAddEventArgs evt) + public async Task Handle(Shard shard, MessageReactionAddEvent evt) { - await TryHandleProxyMessageReactions(shard, evt); + // await TryHandleProxyMessageReactions(shard, evt); } private async ValueTask TryHandleProxyMessageReactions(DiscordClient shard, MessageReactionAddEventArgs evt) diff --git a/PluralKit.Bot/Init.cs b/PluralKit.Bot/Init.cs index f3255434..630fd297 100644 --- a/PluralKit.Bot/Init.cs +++ b/PluralKit.Bot/Init.cs @@ -4,10 +4,11 @@ using System.Threading.Tasks; using Autofac; -using DSharpPlus; - using Microsoft.Extensions.Configuration; +using Myriad.Gateway; +using Myriad.Rest; + using PluralKit.Core; using Serilog; @@ -47,7 +48,8 @@ namespace PluralKit.Bot // Start the Discord shards themselves (handlers already set up) logger.Information("Connecting to Discord"); - await services.Resolve().StartAsync(); + var info = await services.Resolve().GetGatewayBot(); + await services.Resolve().Start(info); logger.Information("Connected! All is good (probably)."); // Lastly, we just... wait. Everything else is handled in the DiscordClient event loop diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index b960a835..8dfd189c 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -6,12 +6,17 @@ using Autofac; using DSharpPlus; using DSharpPlus.EventArgs; +using Myriad.Cache; +using Myriad.Gateway; + using NodaTime; using PluralKit.Core; using Sentry; +using Serilog; + namespace PluralKit.Bot { public class BotModule: Module @@ -30,6 +35,22 @@ namespace PluralKit.Bot builder.Register(c => new DiscordShardedClient(c.Resolve())).AsSelf().SingleInstance(); builder.Register(c => new DiscordRestClient(c.Resolve())).AsSelf().SingleInstance(); + builder.Register(c => new GatewaySettings + { + Token = c.Resolve().Token, + Intents = GatewayIntent.Guilds | + GatewayIntent.DirectMessages | + GatewayIntent.DirectMessageReactions | + GatewayIntent.GuildEmojis | + GatewayIntent.GuildMessages | + GatewayIntent.GuildWebhooks | + GatewayIntent.GuildMessageReactions + }).AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); + builder.Register(c => new Myriad.Rest.DiscordApiClient(c.Resolve().Token, c.Resolve())) + .AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().As().SingleInstance(); + // Commands builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); @@ -55,10 +76,10 @@ namespace PluralKit.Bot // Bot core builder.RegisterType().AsSelf().SingleInstance(); - builder.RegisterType().As>(); - builder.RegisterType().As>().As>(); - builder.RegisterType().As>(); - builder.RegisterType().As>(); + builder.RegisterType().As>(); + builder.RegisterType().As>().As>(); + builder.RegisterType().As>(); + builder.RegisterType().As>(); // Event handler queue builder.RegisterType>().AsSelf().SingleInstance(); @@ -81,13 +102,14 @@ namespace PluralKit.Bot // Sentry stuff builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope(); - builder.RegisterType() - .As>() - .As>() - .As>() - .As>() - .As>() - .SingleInstance(); + // TODO: + // builder.RegisterType() + // .As>() + // .As>() + // .As>() + // .As>() + // .As>() + // .SingleInstance(); // Proxy stuff builder.RegisterType().AsSelf().SingleInstance(); diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 7063f7de..031a901b 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -11,6 +11,7 @@ + diff --git a/PluralKit.Bot/Proxy/ProxyService.cs b/PluralKit.Bot/Proxy/ProxyService.cs index ed02af8c..20330f33 100644 --- a/PluralKit.Bot/Proxy/ProxyService.cs +++ b/PluralKit.Bot/Proxy/ProxyService.cs @@ -7,9 +7,13 @@ using System.Threading.Tasks; using App.Metrics; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.Exceptions; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Exceptions; +using Myriad.Rest.Types.Requests; +using Myriad.Types; using PluralKit.Core; @@ -28,9 +32,11 @@ namespace PluralKit.Bot private readonly WebhookExecutorService _webhookExecutor; private readonly ProxyMatcher _matcher; private readonly IMetrics _metrics; + private readonly IDiscordCache _cache; + private readonly DiscordApiClient _rest; public ProxyService(LogChannelService logChannel, ILogger logger, - WebhookExecutorService webhookExecutor, IDatabase db, ProxyMatcher matcher, IMetrics metrics, ModelRepository repo) + WebhookExecutorService webhookExecutor, IDatabase db, ProxyMatcher matcher, IMetrics metrics, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest) { _logChannel = logChannel; _webhookExecutor = webhookExecutor; @@ -38,71 +44,75 @@ namespace PluralKit.Bot _matcher = matcher; _metrics = metrics; _repo = repo; + _cache = cache; + _rest = rest; _logger = logger.ForContext(); } - public async Task HandleIncomingMessage(DiscordClient shard, DiscordMessage message, MessageContext ctx, bool allowAutoproxy) + public async Task HandleIncomingMessage(Shard shard, MessageCreateEvent message, MessageContext ctx, Guild guild, Channel channel, bool allowAutoproxy, PermissionSet botPermissions) { - if (!ShouldProxy(message, ctx)) return false; + if (!ShouldProxy(channel, message, ctx)) + return false; // Fetch members and try to match to a specific member await using var conn = await _db.Obtain(); List members; using (_metrics.Measure.Timer.Time(BotMetrics.ProxyMembersQueryTime)) - members = (await _repo.GetProxyMembers(conn, message.Author.Id, message.Channel.GuildId)).ToList(); + members = (await _repo.GetProxyMembers(conn, message.Author.Id, message.GuildId!.Value)).ToList(); - if (!_matcher.TryMatch(ctx, members, out var match, message.Content, message.Attachments.Count > 0, + if (!_matcher.TryMatch(ctx, members, out var match, message.Content, message.Attachments.Length > 0, allowAutoproxy)) return false; // Permission check after proxy match so we don't get spammed when not actually proxying - if (!await CheckBotPermissionsOrError(message.Channel)) return false; + if (!await CheckBotPermissionsOrError(botPermissions, message.ChannelId)) + return false; // this method throws, so no need to wrap it in an if statement CheckProxyNameBoundsOrError(match.Member.ProxyName(ctx)); // Check if the sender account can mention everyone/here + embed links // we need to "mirror" these permissions when proxying to prevent exploits - var senderPermissions = message.Channel.PermissionsInSync(message.Author); - var allowEveryone = (senderPermissions & Permissions.MentionEveryone) != 0; - var allowEmbeds = (senderPermissions & Permissions.EmbedLinks) != 0; + var senderPermissions = PermissionExtensions.PermissionsFor(guild, channel, message); + var allowEveryone = senderPermissions.HasFlag(PermissionSet.MentionEveryone); + var allowEmbeds = senderPermissions.HasFlag(PermissionSet.EmbedLinks); // Everything's in order, we can execute the proxy! await ExecuteProxy(shard, conn, message, ctx, match, allowEveryone, allowEmbeds); return true; } - private bool ShouldProxy(DiscordMessage msg, MessageContext ctx) + private bool ShouldProxy(Channel channel, Message msg, MessageContext ctx) { // Make sure author has a system if (ctx.SystemId == null) return false; // Make sure channel is a guild text channel and this is a normal message - if ((msg.Channel.Type != ChannelType.Text && msg.Channel.Type != ChannelType.News) || msg.MessageType != MessageType.Default) return false; + if ((channel.Type != Channel.ChannelType.GuildText && channel.Type != Channel.ChannelType.GuildNews) || msg.Type != Message.MessageType.Default) return false; // Make sure author is a normal user - if (msg.Author.IsSystem == true || msg.Author.IsBot || msg.WebhookMessage) return false; + if (msg.Author.System == true || msg.Author.Bot || msg.WebhookId != null) return false; // Make sure proxying is enabled here if (!ctx.ProxyEnabled || ctx.InBlacklist) return false; // Make sure we have either an attachment or message content var isMessageBlank = msg.Content == null || msg.Content.Trim().Length == 0; - if (isMessageBlank && msg.Attachments.Count == 0) return false; + if (isMessageBlank && msg.Attachments.Length == 0) return false; // All good! return true; } - private async Task ExecuteProxy(DiscordClient shard, IPKConnection conn, DiscordMessage trigger, MessageContext ctx, + private async Task ExecuteProxy(Shard shard, IPKConnection conn, Message trigger, MessageContext ctx, ProxyMatch match, bool allowEveryone, bool allowEmbeds) { // Create reply embed - var embeds = new List(); - if (trigger.Reference?.Channel?.Id == trigger.ChannelId) + var embeds = new List(); + if (trigger.MessageReference?.ChannelId == trigger.ChannelId) { - var repliedTo = await FetchReplyOriginalMessage(trigger.Reference); - var embed = await CreateReplyEmbed(repliedTo); + var repliedTo = await FetchReplyOriginalMessage(trigger.MessageReference); + var embed = CreateReplyEmbed(repliedTo); if (embed != null) embeds.Add(embed); } @@ -110,35 +120,44 @@ namespace PluralKit.Bot // Send the webhook var content = match.ProxyContent; if (!allowEmbeds) content = content.BreakLinkEmbeds(); - var proxyMessage = await _webhookExecutor.ExecuteWebhook(trigger.Channel, FixSingleCharacterName(match.Member.ProxyName(ctx)), - match.Member.ProxyAvatar(ctx), - content, trigger.Attachments, embeds, allowEveryone); + var proxyMessage = await _webhookExecutor.ExecuteWebhook(new ProxyRequest + { + GuildId = trigger.GuildId!.Value, + ChannelId = trigger.ChannelId, + Name = FixSingleCharacterName(match.Member.ProxyName(ctx)), + AvatarUrl = match.Member.ProxyAvatar(ctx), + Content = content, + Attachments = trigger.Attachments, + Embeds = embeds.ToArray(), + AllowEveryone = allowEveryone, + }); await HandleProxyExecutedActions(shard, conn, ctx, trigger, proxyMessage, match); } - private async Task FetchReplyOriginalMessage(DiscordMessageReference reference) + private async Task FetchReplyOriginalMessage(Message.Reference reference) { try { - return await reference.Channel.GetMessageAsync(reference.Message.Id); - } - catch (NotFoundException) - { - _logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but it was not found", - reference.Channel.Id, reference.Message.Id); + var msg = await _rest.GetMessage(reference.ChannelId!.Value, reference.MessageId!.Value); + if (msg == null) + _logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but it was not found", + reference.ChannelId, reference.MessageId); + return msg; } catch (UnauthorizedException) { _logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but bot was not allowed to", - reference.Channel.Id, reference.Message.Id); + reference.ChannelId, reference.MessageId); } return null; } - private async Task CreateReplyEmbed(DiscordMessage original) + private Embed CreateReplyEmbed(Message original) { + var jumpLink = $"https://discord.com/channels/{original.GuildId}/{original.ChannelId}/{original.Id}"; + var content = new StringBuilder(); var hasContent = !string.IsNullOrWhiteSpace(original.Content); @@ -155,40 +174,45 @@ namespace PluralKit.Bot msg += "…"; } - content.Append($"**[Reply to:]({original.JumpLink})** "); + content.Append($"**[Reply to:]({jumpLink})** "); content.Append(msg); - if (original.Attachments.Count > 0) + if (original.Attachments.Length > 0) content.Append($" {Emojis.Paperclip}"); } else { - content.Append($"*[(click to see attachment)]({original.JumpLink})*"); + content.Append($"*[(click to see attachment)]({jumpLink})*"); } - var username = (original.Author as DiscordMember)?.Nickname ?? original.Author.Username; - - return new DiscordEmbedBuilder() + // TODO: get the nickname somehow + var username = original.Author.Username; + // var username = original.Member?.Nick ?? original.Author.Username; + + var avatarUrl = $"https://cdn.discordapp.com/avatars/{original.Author.Id}/{original.Author.Avatar}.png"; + + return new Embed + { // unicodes: [three-per-em space] [left arrow emoji] [force emoji presentation] - .WithAuthor($"{username}\u2004\u21a9\ufe0f", iconUrl: original.Author.AvatarUrl) - .WithDescription(content.ToString()) - .Build(); + Author = new($"{username}\u2004\u21a9\ufe0f", IconUrl: avatarUrl), + Description = content.ToString() + }; } - private async Task HandleProxyExecutedActions(DiscordClient shard, IPKConnection conn, MessageContext ctx, - DiscordMessage triggerMessage, DiscordMessage proxyMessage, + private async Task HandleProxyExecutedActions(Shard shard, IPKConnection conn, MessageContext ctx, + Message triggerMessage, Message proxyMessage, ProxyMatch match) { Task SaveMessageInDatabase() => _repo.AddMessage(conn, new PKMessage { Channel = triggerMessage.ChannelId, - Guild = triggerMessage.Channel.GuildId, + Guild = triggerMessage.GuildId, Member = match.Member.Id, Mid = proxyMessage.Id, OriginalMid = triggerMessage.Id, Sender = triggerMessage.Author.Id }); - Task LogMessageToChannel() => _logChannel.LogMessage(shard, ctx, match, triggerMessage, proxyMessage.Id).AsTask(); + Task LogMessageToChannel() => _logChannel.LogMessage(ctx, match, triggerMessage, proxyMessage.Id).AsTask(); async Task DeleteProxyTriggerMessage() { @@ -196,7 +220,7 @@ namespace PluralKit.Bot await Task.Delay(MessageDeletionDelay); try { - await triggerMessage.DeleteAsync(); + await _rest.DeleteMessage(triggerMessage.ChannelId, triggerMessage.Id); } catch (NotFoundException) { @@ -216,7 +240,7 @@ namespace PluralKit.Bot ); } - private async Task HandleTriggerAlreadyDeleted(DiscordMessage proxyMessage) + private async Task HandleTriggerAlreadyDeleted(Message proxyMessage) { // If a trigger message is deleted before we get to delete it, we can assume a mod bot or similar got to it // In this case we should also delete the now-proxied message. @@ -224,32 +248,35 @@ namespace PluralKit.Bot try { - await proxyMessage.DeleteAsync(); + await _rest.DeleteMessage(proxyMessage.ChannelId, proxyMessage.Id); } catch (NotFoundException) { } catch (UnauthorizedException) { } } - private async Task CheckBotPermissionsOrError(DiscordChannel channel) + private async Task CheckBotPermissionsOrError(PermissionSet permissions, ulong responseChannel) { - var permissions = channel.BotPermissions(); - // If we can't send messages at all, just bail immediately. // 2020-04-22: Manage Messages does *not* override a lack of Send Messages. - if ((permissions & Permissions.SendMessages) == 0) return false; + if (!permissions.HasFlag(PermissionSet.SendMessages)) + return false; - if ((permissions & Permissions.ManageWebhooks) == 0) + if (!permissions.HasFlag(PermissionSet.ManageWebhooks)) { // todo: PKError-ify these - await channel.SendMessageFixedAsync( - $"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages. Please contact a server administrator to remedy this."); + await _rest.CreateMessage(responseChannel, new MessageRequest + { + Content = $"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages. Please contact a server administrator to remedy this." + }); return false; } - if ((permissions & Permissions.ManageMessages) == 0) + if (!permissions.HasFlag(PermissionSet.ManageMessages)) { - await channel.SendMessageFixedAsync( - $"{Emojis.Error} PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the original trigger message. Please contact a server administrator to remedy this."); + await _rest.CreateMessage(responseChannel, new MessageRequest + { + Content = $"{Emojis.Error} PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the original trigger message. Please contact a server administrator to remedy this." + }); return false; } diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index f360221c..241edde3 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -2,8 +2,9 @@ using System.Threading.Tasks; using Dapper; -using DSharpPlus; -using DSharpPlus.Entities; +using Myriad.Cache; +using Myriad.Rest; +using Myriad.Types; using PluralKit.Core; @@ -15,56 +16,62 @@ namespace PluralKit.Bot { private readonly IDatabase _db; private readonly ModelRepository _repo; private readonly ILogger _logger; + private readonly IDiscordCache _cache; + private readonly DiscordApiClient _rest; - public LogChannelService(EmbedService embed, ILogger logger, IDatabase db, ModelRepository repo) + public LogChannelService(EmbedService embed, ILogger logger, IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest) { _embed = embed; _db = db; _repo = repo; + _cache = cache; + _rest = rest; _logger = logger.ForContext(); } - public async ValueTask LogMessage(DiscordClient client, MessageContext ctx, ProxyMatch proxy, DiscordMessage trigger, ulong hookMessage) + public async ValueTask LogMessage(MessageContext ctx, ProxyMatch proxy, Message trigger, ulong hookMessage) { if (ctx.SystemId == null || ctx.LogChannel == null || ctx.InLogBlacklist) return; // Find log channel and check if valid - var logChannel = await FindLogChannel(client, trigger.Channel.GuildId, ctx.LogChannel.Value); - if (logChannel == null || logChannel.Type != ChannelType.Text) return; + var logChannel = await FindLogChannel(trigger.GuildId!.Value, ctx.LogChannel.Value); + if (logChannel == null || logChannel.Type != Channel.ChannelType.GuildText) return; // Check bot permissions - if (!logChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks)) - { - _logger.Information( - "Does not have permission to proxy log, ignoring (channel: {ChannelId}, guild: {GuildId}, bot permissions: {BotPermissions})", - ctx.LogChannel.Value, trigger.Channel.GuildId, trigger.Channel.BotPermissions()); - return; - } - + // if (!logChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks)) + // { + // _logger.Information( + // "Does not have permission to proxy log, ignoring (channel: {ChannelId}, guild: {GuildId}, bot permissions: {BotPermissions})", + // ctx.LogChannel.Value, trigger.GuildId!.Value, trigger.Channel.BotPermissions()); + // return; + // } + // // Send embed! - await using var conn = await _db.Obtain(); - var embed = _embed.CreateLoggedMessageEmbed(await _repo.GetSystem(conn, ctx.SystemId.Value), - await _repo.GetMember(conn, proxy.Member.Id), hookMessage, trigger.Id, trigger.Author, proxy.Content, - trigger.Channel); - var url = $"https://discord.com/channels/{trigger.Channel.GuildId}/{trigger.ChannelId}/{hookMessage}"; - await logChannel.SendMessageFixedAsync(content: url, embed: embed); + + // TODO: fix? + // await using var conn = await _db.Obtain(); + // var embed = _embed.CreateLoggedMessageEmbed(await _repo.GetSystem(conn, ctx.SystemId.Value), + // await _repo.GetMember(conn, proxy.Member.Id), hookMessage, trigger.Id, trigger.Author, proxy.Content, + // trigger.Channel); + // var url = $"https://discord.com/channels/{trigger.Channel.GuildId}/{trigger.ChannelId}/{hookMessage}"; + // await logChannel.SendMessageFixedAsync(content: url, embed: embed); } - private async Task FindLogChannel(DiscordClient client, ulong guild, ulong channel) + private async Task FindLogChannel(ulong guildId, ulong channelId) { - // MUST use this client here, otherwise we get strange cache issues where the guild doesn't exist... >.> - var obj = await client.GetChannel(channel); + // TODO: fetch it directly on cache miss? + var channel = await _cache.GetChannel(channelId); - if (obj == null) + if (channel == null) { // Channel doesn't exist or we don't have permission to access it, let's remove it from the database too - _logger.Warning("Attempted to fetch missing log channel {LogChannel} for guild {Guild}, removing from database", channel, guild); + _logger.Warning("Attempted to fetch missing log channel {LogChannel} for guild {Guild}, removing from database", channelId, guildId); await using var conn = await _db.Obtain(); await conn.ExecuteAsync("update servers set log_channel = null where id = @Guild", - new {Guild = guild}); + new {Guild = guildId}); } - return obj; + return channel; } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/LoggerCleanService.cs b/PluralKit.Bot/Services/LoggerCleanService.cs index 41ff4fa6..b0b56b9e 100644 --- a/PluralKit.Bot/Services/LoggerCleanService.cs +++ b/PluralKit.Bot/Services/LoggerCleanService.cs @@ -4,11 +4,10 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using Dapper; - using DSharpPlus; using DSharpPlus.Entities; -using DSharpPlus.Exceptions; + +using Myriad.Types; using PluralKit.Core; @@ -68,8 +67,10 @@ namespace PluralKit.Bot public ICollection Bots => _bots.Values; - public async ValueTask HandleLoggerBotCleanup(DiscordMessage msg) + public async ValueTask HandleLoggerBotCleanup(Message msg) { + // TODO: fix!! + /* if (msg.Channel.Type != ChannelType.Text) return; if (!msg.Channel.BotHasAllPermissions(Permissions.ManageMessages)) return; @@ -130,6 +131,7 @@ namespace PluralKit.Bot // The only thing I can think of that'd cause this are the DeleteAsync() calls which 404 when // the message doesn't exist anyway - so should be safe to just ignore it, right? } + */ } private static ulong? ExtractAuttaja(DiscordMessage msg) diff --git a/PluralKit.Bot/Services/WebhookCacheService.cs b/PluralKit.Bot/Services/WebhookCacheService.cs index 99caf97e..9005c276 100644 --- a/PluralKit.Bot/Services/WebhookCacheService.cs +++ b/PluralKit.Bot/Services/WebhookCacheService.cs @@ -1,14 +1,15 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Threading.Tasks; using App.Metrics; -using DSharpPlus; -using DSharpPlus.Entities; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Types.Requests; +using Myriad.Types; using Serilog; @@ -17,38 +18,32 @@ namespace PluralKit.Bot public class WebhookCacheService { public static readonly string WebhookName = "PluralKit Proxy Webhook"; - - private readonly DiscordShardedClient _client; - private readonly ConcurrentDictionary>> _webhooks; + + private readonly DiscordApiClient _rest; + private readonly ConcurrentDictionary>> _webhooks; private readonly IMetrics _metrics; private readonly ILogger _logger; + private readonly Cluster _cluster; - public WebhookCacheService(DiscordShardedClient client, ILogger logger, IMetrics metrics) + public WebhookCacheService(ILogger logger, IMetrics metrics, DiscordApiClient rest, Cluster cluster) { - _client = client; _metrics = metrics; + _rest = rest; + _cluster = cluster; _logger = logger.ForContext(); - _webhooks = new ConcurrentDictionary>>(); + _webhooks = new ConcurrentDictionary>>(); } - - public async Task GetWebhook(DiscordClient client, ulong channelId) - { - var channel = await client.GetChannel(channelId); - if (channel == null) return null; - if (channel.Type == ChannelType.Text) return null; - return await GetWebhook(channel); - } - - public async Task GetWebhook(DiscordChannel channel) + + public async Task GetWebhook(ulong channelId) { // We cache the webhook through a Lazy>, this way we make sure to only create one webhook per channel // If the webhook is requested twice before it's actually been found, the Lazy wrapper will stop the // webhook from being created twice. - Lazy> GetWebhookTaskInner() + Lazy> GetWebhookTaskInner() { - Task Factory() => GetOrCreateWebhook(channel); - return _webhooks.GetOrAdd(channel.Id, new Lazy>(Factory)); + Task Factory() => GetOrCreateWebhook(channelId); + return _webhooks.GetOrAdd(channelId, new Lazy>(Factory)); } var lazyWebhookValue = GetWebhookTaskInner(); @@ -57,36 +52,38 @@ namespace PluralKit.Bot // although, keep in mind this block gets hit the call *after* the task failed (since we only await it below) if (lazyWebhookValue.IsValueCreated && lazyWebhookValue.Value.IsFaulted) { - _logger.Warning(lazyWebhookValue.Value.Exception, "Cached webhook task for {Channel} faulted with below exception", channel.Id); + _logger.Warning(lazyWebhookValue.Value.Exception, "Cached webhook task for {Channel} faulted with below exception", channelId); // Specifically don't recurse here so we don't infinite-loop - if this one errors too, it'll "stick" // until next time this function gets hit (which is okay, probably). - _webhooks.TryRemove(channel.Id, out _); + _webhooks.TryRemove(channelId, out _); lazyWebhookValue = GetWebhookTaskInner(); } // It's possible to "move" a webhook to a different channel after creation // Here, we ensure it's actually still pointing towards the proper channel, and if not, wipe and refetch one. var webhook = await lazyWebhookValue.Value; - if (webhook.ChannelId != channel.Id && webhook.ChannelId != 0) return await InvalidateAndRefreshWebhook(channel, webhook); + if (webhook.ChannelId != channelId && webhook.ChannelId != 0) + return await InvalidateAndRefreshWebhook(channelId, webhook); return webhook; } - public async Task InvalidateAndRefreshWebhook(DiscordChannel channel, DiscordWebhook webhook) + public async Task InvalidateAndRefreshWebhook(ulong channelId, Webhook webhook) { + // note: webhook.ChannelId may not be the same as channelId >.> _logger.Information("Refreshing webhook for channel {Channel}", webhook.ChannelId); _webhooks.TryRemove(webhook.ChannelId, out _); - return await GetWebhook(channel); + return await GetWebhook(channelId); } - private async Task GetOrCreateWebhook(DiscordChannel channel) + private async Task GetOrCreateWebhook(ulong channelId) { - _logger.Debug("Webhook for channel {Channel} not found in cache, trying to fetch", channel.Id); + _logger.Debug("Webhook for channel {Channel} not found in cache, trying to fetch", channelId); _metrics.Measure.Meter.Mark(BotMetrics.WebhookCacheMisses); - _logger.Debug("Finding webhook for channel {Channel}", channel.Id); - var webhooks = await FetchChannelWebhooks(channel); + _logger.Debug("Finding webhook for channel {Channel}", channelId); + var webhooks = await FetchChannelWebhooks(channelId); // If the channel has a webhook created by PK, just return that one var ourWebhook = webhooks.FirstOrDefault(IsWebhookMine); @@ -95,17 +92,17 @@ namespace PluralKit.Bot // We don't have one, so we gotta create a new one // but first, make sure we haven't hit the webhook cap yet... - if (webhooks.Count >= 10) + if (webhooks.Length >= 10) throw new PKError("This channel has the maximum amount of possible webhooks (10) already created. A server admin must delete one or more webhooks so PluralKit can create one for proxying."); - return await DoCreateWebhook(channel); + return await DoCreateWebhook(channelId); } - private async Task> FetchChannelWebhooks(DiscordChannel channel) + private async Task FetchChannelWebhooks(ulong channelId) { try { - return await channel.GetWebhooksAsync(); + return await _rest.GetChannelWebhooks(channelId); } catch (HttpRequestException e) { @@ -113,33 +110,17 @@ namespace PluralKit.Bot // This happens sometimes when Discord returns a malformed request for the webhook list // Nothing we can do than just assume that none exist. - return new DiscordWebhook[0]; + return new Webhook[0]; } } - - private async Task FindExistingWebhook(DiscordChannel channel) + + private async Task DoCreateWebhook(ulong channelId) { - _logger.Debug("Finding webhook for channel {Channel}", channel.Id); - try - { - return (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine); - } - catch (HttpRequestException e) - { - _logger.Warning(e, "Error occurred while fetching webhook list"); - // This happens sometimes when Discord returns a malformed request for the webhook list - // Nothing we can do than just assume that none exist and return null. - return null; - } + _logger.Information("Creating new webhook for channel {Channel}", channelId); + return await _rest.CreateWebhook(channelId, new CreateWebhookRequest(WebhookName)); } - private Task DoCreateWebhook(DiscordChannel channel) - { - _logger.Information("Creating new webhook for channel {Channel}", channel.Id); - return channel.CreateWebhookAsync(WebhookName); - } - - private bool IsWebhookMine(DiscordWebhook arg) => arg.User.Id == _client.CurrentUser.Id && arg.Name == WebhookName; + private bool IsWebhookMine(Webhook arg) => arg.User?.Id == _cluster.User?.Id && arg.Name == WebhookName; public int CacheSize => _webhooks.Count; } diff --git a/PluralKit.Bot/Services/WebhookExecutorService.cs b/PluralKit.Bot/Services/WebhookExecutorService.cs index c307562d..61efabbe 100644 --- a/PluralKit.Bot/Services/WebhookExecutorService.cs +++ b/PluralKit.Bot/Services/WebhookExecutorService.cs @@ -1,19 +1,20 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; using App.Metrics; -using DSharpPlus.Entities; -using DSharpPlus.Exceptions; - using Humanizer; +using Myriad.Cache; +using Myriad.Rest; +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; +using Myriad.Types; + using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; using Serilog; @@ -26,64 +27,84 @@ namespace PluralKit.Bot // Exceptions for control flow? don't mind if I do // TODO: rewrite both of these as a normal exceptional return value (0?) in case of error to be discarded by caller } + + public record ProxyRequest + { + public ulong GuildId { get; init; } + public ulong ChannelId { get; init; } + public string Name { get; init; } + public string? AvatarUrl { get; init; } + public string? Content { get; init; } + public Message.Attachment[] Attachments { get; init; } + public Embed[] Embeds { get; init; } + public bool AllowEveryone { get; init; } + } public class WebhookExecutorService { + private readonly IDiscordCache _cache; private readonly WebhookCacheService _webhookCache; + private readonly DiscordApiClient _rest; private readonly ILogger _logger; private readonly IMetrics _metrics; private readonly HttpClient _client; - public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client) + public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client, IDiscordCache cache, DiscordApiClient rest) { _metrics = metrics; _webhookCache = webhookCache; _client = client; + _cache = cache; + _rest = rest; _logger = logger.ForContext(); } - public async Task ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList attachments, IReadOnlyList embeds, bool allowEveryone) + public async Task ExecuteWebhook(ProxyRequest req) { - _logger.Verbose("Invoking webhook in channel {Channel}", channel.Id); + _logger.Verbose("Invoking webhook in channel {Channel}", req.ChannelId); // Get a webhook, execute it - var webhook = await _webhookCache.GetWebhook(channel); - var webhookMessage = await ExecuteWebhookInner(channel, webhook, name, avatarUrl, content, attachments, embeds, allowEveryone); + var webhook = await _webhookCache.GetWebhook(req.ChannelId); + var webhookMessage = await ExecuteWebhookInner(webhook, req); // Log the relevant metrics _metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied); _logger.Information("Invoked webhook {Webhook} in channel {Channel}", webhook.Id, - channel.Id); + req.ChannelId); return webhookMessage; } - private async Task ExecuteWebhookInner( - DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content, - IReadOnlyList attachments, IReadOnlyList embeds, bool allowEveryone, bool hasRetried = false) + private async Task ExecuteWebhookInner(Webhook webhook, ProxyRequest req, bool hasRetried = false) { - content = content.Truncate(2000); + var guild = await _cache.GetGuild(req.GuildId)!; + var content = req.Content.Truncate(2000); - var dwb = new DiscordWebhookBuilder(); - dwb.WithUsername(FixClyde(name).Truncate(80)); - dwb.WithContent(content); - dwb.AddMentions(content.ParseAllMentions(allowEveryone, channel.Guild)); - if (!string.IsNullOrWhiteSpace(avatarUrl)) - dwb.WithAvatarUrl(avatarUrl); - dwb.AddEmbeds(embeds); + var webhookReq = new ExecuteWebhookRequest + { + Username = FixClyde(req.Name).Truncate(80), + Content = content, + AllowedMentions = null, // todo + AvatarUrl = !string.IsNullOrWhiteSpace(req.AvatarUrl) ? req.AvatarUrl : null, + Embeds = req.Embeds + }; - var attachmentChunks = ChunkAttachmentsOrThrow(attachments, 8 * 1024 * 1024); + // dwb.AddMentions(content.ParseAllMentions(guild, req.AllowEveryone)); + + MultipartFile[] files = null; + var attachmentChunks = ChunkAttachmentsOrThrow(req.Attachments, 8 * 1024 * 1024); if (attachmentChunks.Count > 0) { - _logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks", attachments.Count, attachments.Select(a => a.FileSize).Sum() / 1024 / 1024, attachmentChunks.Count); - await AddAttachmentsToBuilder(dwb, attachmentChunks[0]); + _logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks", + req.Attachments.Length, req.Attachments.Select(a => a.Size).Sum() / 1024 / 1024, attachmentChunks.Count); + files = await GetAttachmentFiles(attachmentChunks[0]); } - DiscordMessage webhookMessage; + Message webhookMessage; using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime)) { try { - webhookMessage = await webhook.ExecuteAsync(dwb); + webhookMessage = await _rest.ExecuteWebhook(webhook.Id, webhook.Token, webhookReq, files); } catch (JsonReaderException) { @@ -91,17 +112,16 @@ namespace PluralKit.Bot // Nothing we can do about this - happens sometimes under server load, so just drop the message and give up throw new WebhookExecutionErrorOnDiscordsEnd(); } - catch (NotFoundException e) + catch (Myriad.Rest.Exceptions.NotFoundException e) { - var errorText = e.WebResponse?.Response; - if (errorText != null && errorText.Contains("10015") && !hasRetried) + if (e.ErrorCode == 10015 && !hasRetried) { // Error 10015 = "Unknown Webhook" - this likely means the webhook was deleted // but is still in our cache. Invalidate, refresh, try again _logger.Warning("Error invoking webhook {Webhook} in channel {Channel}", webhook.Id, webhook.ChannelId); - var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(channel, webhook); - return await ExecuteWebhookInner(channel, newWebhook, name, avatarUrl, content, attachments, embeds, allowEveryone, hasRetried: true); + var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(req.ChannelId, webhook); + return await ExecuteWebhookInner(newWebhook, req, hasRetried: true); } throw; @@ -109,53 +129,50 @@ namespace PluralKit.Bot } // We don't care about whether the sending succeeds, and we don't want to *wait* for it, so we just fork it off - var _ = TrySendRemainingAttachments(webhook, name, avatarUrl, attachmentChunks); + var _ = TrySendRemainingAttachments(webhook, req.Name, req.AvatarUrl, attachmentChunks); return webhookMessage; } - private async Task TrySendRemainingAttachments(DiscordWebhook webhook, string name, string avatarUrl, IReadOnlyList> attachmentChunks) + private async Task TrySendRemainingAttachments(Webhook webhook, string name, string avatarUrl, IReadOnlyList> attachmentChunks) { if (attachmentChunks.Count <= 1) return; for (var i = 1; i < attachmentChunks.Count; i++) { - var dwb = new DiscordWebhookBuilder(); - if (avatarUrl != null) dwb.WithAvatarUrl(avatarUrl); - dwb.WithUsername(name); - await AddAttachmentsToBuilder(dwb, attachmentChunks[i]); - await webhook.ExecuteAsync(dwb); + var files = await GetAttachmentFiles(attachmentChunks[i]); + var req = new ExecuteWebhookRequest {Username = name, AvatarUrl = avatarUrl}; + await _rest.ExecuteWebhook(webhook.Id, webhook.Token!, req, files); } } - - private async Task AddAttachmentsToBuilder(DiscordWebhookBuilder dwb, IReadOnlyCollection attachments) + + private async Task GetAttachmentFiles(IReadOnlyCollection attachments) { - async Task<(DiscordAttachment, Stream)> GetStream(DiscordAttachment attachment) + async Task GetStream(Message.Attachment attachment) { var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead); - return (attachment, await attachmentResponse.Content.ReadAsStreamAsync()); + return new(attachment.Filename, await attachmentResponse.Content.ReadAsStreamAsync()); } - - foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream))) - dwb.AddFile(attachment.FileName, attachmentStream); + + return await Task.WhenAll(attachments.Select(GetStream)); } - private IReadOnlyList> ChunkAttachmentsOrThrow( - IReadOnlyList attachments, int sizeThreshold) + private IReadOnlyList> ChunkAttachmentsOrThrow( + IReadOnlyList attachments, int sizeThreshold) { // Splits a list of attachments into "chunks" of at most 8MB each // If any individual attachment is larger than 8MB, will throw an error - var chunks = new List>(); - var list = new List(); + var chunks = new List>(); + var list = new List(); foreach (var attachment in attachments) { - if (attachment.FileSize >= sizeThreshold) throw Errors.AttachmentTooLarge; + if (attachment.Size >= sizeThreshold) throw Errors.AttachmentTooLarge; - if (list.Sum(a => a.FileSize) + attachment.FileSize >= sizeThreshold) + if (list.Sum(a => a.Size) + attachment.Size >= sizeThreshold) { chunks.Add(list); - list = new List(); + list = new List(); } list.Add(attachment); diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index 90075445..903f2b31 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -12,10 +12,14 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using DSharpPlus.Exceptions; +using Myriad.Types; + using NodaTime; using PluralKit.Core; +using Permissions = DSharpPlus.Permissions; + namespace PluralKit.Bot { public static class DiscordUtils @@ -190,8 +194,7 @@ namespace PluralKit.Bot return false; } - public static IEnumerable ParseAllMentions(this string input, bool allowEveryone = false, - DiscordGuild guild = null) + public static IEnumerable ParseAllMentions(this string input, Guild guild, bool allowEveryone = false) { var mentions = new List(); mentions.AddRange(USER_MENTION.Matches(input) @@ -203,7 +206,7 @@ namespace PluralKit.Bot // Original fix by Gwen mentions.AddRange(ROLE_MENTION.Matches(input) .Select(x => ulong.Parse(x.Groups[1].Value)) - .Where(x => allowEveryone || guild != null && guild.GetRole(x).IsMentionable) + .Where(x => allowEveryone || guild != null && (guild.Roles.FirstOrDefault(g => g.Id == x)?.Mentionable ?? false)) .Select(x => new RoleMention(x) as IMention)); if (EVERYONE_HERE_MENTION.IsMatch(input) && allowEveryone) mentions.Add(new EveryoneMention()); diff --git a/PluralKit.Bot/Utils/SentryUtils.cs b/PluralKit.Bot/Utils/SentryUtils.cs index 7b11f876..0d9cfb19 100644 --- a/PluralKit.Bot/Utils/SentryUtils.cs +++ b/PluralKit.Bot/Utils/SentryUtils.cs @@ -4,26 +4,29 @@ using System.Linq; using DSharpPlus; using DSharpPlus.EventArgs; +using Myriad.Gateway; + using Sentry; namespace PluralKit.Bot { - public interface ISentryEnricher where T: DiscordEventArgs + public interface ISentryEnricher where T: IGatewayEvent { - void Enrich(Scope scope, DiscordClient shard, T evt); + void Enrich(Scope scope, Shard shard, T evt); } - public class SentryEnricher: - ISentryEnricher, - ISentryEnricher, - ISentryEnricher, - ISentryEnricher, - ISentryEnricher + public class SentryEnricher //: + // TODO!!! + // ISentryEnricher, + // ISentryEnricher, + // ISentryEnricher, + // ISentryEnricher, + // ISentryEnricher { // TODO: should this class take the Scope by dependency injection instead? // Would allow us to create a centralized "chain of handlers" where this class could just be registered as an entry in - public void Enrich(Scope scope, DiscordClient shard, MessageCreateEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageCreateEventArgs evt) { scope.AddBreadcrumb(evt.Message.Content, "event.message", data: new Dictionary { @@ -32,7 +35,7 @@ namespace PluralKit.Bot {"guild", evt.Channel.GuildId.ToString()}, {"message", evt.Message.Id.ToString()}, }); - scope.SetTag("shard", shard.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); // Also report information about the bot's permissions in the channel // We get a lot of permission errors so this'll be useful for determining problems @@ -40,7 +43,7 @@ namespace PluralKit.Bot scope.AddBreadcrumb(perms.ToPermissionString(), "permissions"); } - public void Enrich(Scope scope, DiscordClient shard, MessageDeleteEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageDeleteEventArgs evt) { scope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() @@ -49,10 +52,10 @@ namespace PluralKit.Bot {"guild", evt.Channel.GuildId.ToString()}, {"message", evt.Message.Id.ToString()}, }); - scope.SetTag("shard", shard.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); } - public void Enrich(Scope scope, DiscordClient shard, MessageUpdateEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageUpdateEventArgs evt) { scope.AddBreadcrumb(evt.Message.Content ?? "", "event.messageEdit", data: new Dictionary() @@ -61,10 +64,10 @@ namespace PluralKit.Bot {"guild", evt.Channel.GuildId.ToString()}, {"message", evt.Message.Id.ToString()} }); - scope.SetTag("shard", shard.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); } - public void Enrich(Scope scope, DiscordClient shard, MessageBulkDeleteEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageBulkDeleteEventArgs evt) { scope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() @@ -73,10 +76,10 @@ namespace PluralKit.Bot {"guild", evt.Channel.Id.ToString()}, {"messages", string.Join(",", evt.Messages.Select(m => m.Id))}, }); - scope.SetTag("shard", shard.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); } - public void Enrich(Scope scope, DiscordClient shard, MessageReactionAddEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageReactionAddEventArgs evt) { scope.AddBreadcrumb("", "event.reaction", data: new Dictionary() @@ -87,7 +90,7 @@ namespace PluralKit.Bot {"message", evt.Message.Id.ToString()}, {"reaction", evt.Emoji.Name} }); - scope.SetTag("shard", shard.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); } } } \ No newline at end of file diff --git a/PluralKit.sln b/PluralKit.sln index 84b03bec..05212782 100644 --- a/PluralKit.sln +++ b/PluralKit.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.API", "PluralKit. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Tests", "PluralKit.Tests\PluralKit.Tests.csproj", "{752FE725-5EE1-45E9-B721-0CDD28171AC8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Myriad", "Myriad\Myriad.csproj", "{ACB9BF37-F29C-4068-A7D1-2EFF2C308C4B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,5 +32,9 @@ Global {752FE725-5EE1-45E9-B721-0CDD28171AC8}.Debug|Any CPU.Build.0 = Debug|Any CPU {752FE725-5EE1-45E9-B721-0CDD28171AC8}.Release|Any CPU.ActiveCfg = Release|Any CPU {752FE725-5EE1-45E9-B721-0CDD28171AC8}.Release|Any CPU.Build.0 = Release|Any CPU + {ACB9BF37-F29C-4068-A7D1-2EFF2C308C4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ACB9BF37-F29C-4068-A7D1-2EFF2C308C4B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ACB9BF37-F29C-4068-A7D1-2EFF2C308C4B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ACB9BF37-F29C-4068-A7D1-2EFF2C308C4B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 05334f0d259583872fa5d8feaa1bd75aed264b9e Mon Sep 17 00:00:00 2001 From: Ske Date: Tue, 22 Dec 2020 16:55:13 +0100 Subject: [PATCH 02/26] Converted enough to send the system card --- Myriad/Cache/IDiscordCache.cs | 10 +-- Myriad/Cache/MemoryDiscordCache.cs | 25 +++++-- Myriad/Extensions/CacheExtensions.cs | 45 ++++++++++++ Myriad/Extensions/ChannelExtensions.cs | 6 +- Myriad/Extensions/GuildExtensions.cs | 7 ++ Myriad/Extensions/MessageExtensions.cs | 3 +- Myriad/Extensions/PermissionExtensions.cs | 33 +++++++-- Myriad/Extensions/UserExtensions.cs | 2 + Myriad/Rest/Types/AllowedMentions.cs | 6 +- Myriad/Rest/Types/Requests/MessageRequest.cs | 2 +- Myriad/Types/Channel.cs | 2 +- PluralKit.Bot/CommandSystem/Context.cs | 52 +++++++++---- .../CommandSystem/ContextChecksExt.cs | 11 ++- .../ContextEntityArgumentsExt.cs | 11 ++- PluralKit.Bot/Commands/CommandTree.cs | 1 - PluralKit.Bot/Commands/MemberAvatar.cs | 16 ++-- PluralKit.Bot/Commands/MemberEdit.cs | 39 +++++----- PluralKit.Bot/Commands/ServerConfig.cs | 73 +++++++++++-------- PluralKit.Bot/Commands/Switch.cs | 2 - PluralKit.Bot/Commands/SystemEdit.cs | 13 +--- PluralKit.Bot/Commands/SystemFront.cs | 1 - PluralKit.Bot/Commands/SystemList.cs | 2 - PluralKit.Bot/Handlers/MessageCreated.cs | 8 +- PluralKit.Bot/Services/EmbedService.cs | 65 ++++++++++++----- PluralKit.Bot/Services/LogChannelService.cs | 18 ++--- .../Services/WebhookExecutorService.cs | 11 ++- PluralKit.Bot/Utils/ContextUtils.cs | 21 +++--- PluralKit.Bot/Utils/DiscordUtils.cs | 56 +++++++------- 28 files changed, 343 insertions(+), 198 deletions(-) create mode 100644 Myriad/Extensions/CacheExtensions.cs create mode 100644 Myriad/Extensions/GuildExtensions.cs diff --git a/Myriad/Cache/IDiscordCache.cs b/Myriad/Cache/IDiscordCache.cs index fdc348c6..7c72b272 100644 --- a/Myriad/Cache/IDiscordCache.cs +++ b/Myriad/Cache/IDiscordCache.cs @@ -17,12 +17,12 @@ namespace Myriad.Cache public ValueTask RemoveUser(ulong userId); public ValueTask RemoveRole(ulong guildId, ulong roleId); - public ValueTask GetGuild(ulong guildId); - public ValueTask GetChannel(ulong channelId); - public ValueTask GetUser(ulong userId); - public ValueTask GetRole(ulong roleId); + public bool TryGetGuild(ulong guildId, out Guild guild); + public bool TryGetChannel(ulong channelId, out Channel channel); + public bool TryGetUser(ulong userId, out User user); + public bool TryGetRole(ulong roleId, out Role role); public IAsyncEnumerable GetAllGuilds(); - public ValueTask> GetGuildChannels(ulong guildId); + public IEnumerable GetGuildChannels(ulong guildId); } } \ No newline at end of file diff --git a/Myriad/Cache/MemoryDiscordCache.cs b/Myriad/Cache/MemoryDiscordCache.cs index 8ba50366..2a6c194f 100644 --- a/Myriad/Cache/MemoryDiscordCache.cs +++ b/Myriad/Cache/MemoryDiscordCache.cs @@ -110,13 +110,26 @@ namespace Myriad.Cache return default; } - public ValueTask GetGuild(ulong guildId) => new(_guilds.GetValueOrDefault(guildId)?.Guild); + public bool TryGetGuild(ulong guildId, out Guild guild) + { + if (_guilds.TryGetValue(guildId, out var cg)) + { + guild = cg.Guild; + return true; + } - public ValueTask GetChannel(ulong channelId) => new(_channels.GetValueOrDefault(channelId)); + guild = null!; + return false; + } - public ValueTask GetUser(ulong userId) => new(_users.GetValueOrDefault(userId)); + public bool TryGetChannel(ulong channelId, out Channel channel) => + _channels.TryGetValue(channelId, out channel!); - public ValueTask GetRole(ulong roleId) => new(_roles.GetValueOrDefault(roleId)); + public bool TryGetUser(ulong userId, out User user) => + _users.TryGetValue(userId, out user!); + + public bool TryGetRole(ulong roleId, out Role role) => + _roles.TryGetValue(roleId, out role!); public async IAsyncEnumerable GetAllGuilds() { @@ -124,12 +137,12 @@ namespace Myriad.Cache yield return guild.Guild; } - public ValueTask> GetGuildChannels(ulong guildId) + public IEnumerable GetGuildChannels(ulong guildId) { if (!_guilds.TryGetValue(guildId, out var guild)) throw new ArgumentException("Guild not found", nameof(guildId)); - return new ValueTask>(guild.Channels.Keys.Select(c => _channels[c])); + return guild.Channels.Keys.Select(c => _channels[c]); } private CachedGuild SaveGuildRaw(Guild guild) => diff --git a/Myriad/Extensions/CacheExtensions.cs b/Myriad/Extensions/CacheExtensions.cs new file mode 100644 index 00000000..260c5932 --- /dev/null +++ b/Myriad/Extensions/CacheExtensions.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +using Myriad.Cache; +using Myriad.Types; + +namespace Myriad.Extensions +{ + public static class CacheExtensions + { + public static Guild GetGuild(this IDiscordCache cache, ulong guildId) + { + if (!cache.TryGetGuild(guildId, out var guild)) + throw new KeyNotFoundException($"Guild {guildId} not found in cache"); + return guild; + } + + public static Channel GetChannel(this IDiscordCache cache, ulong channelId) + { + if (!cache.TryGetChannel(channelId, out var channel)) + throw new KeyNotFoundException($"Channel {channelId} not found in cache"); + return channel; + } + + public static Channel? GetChannelOrNull(this IDiscordCache cache, ulong channelId) + { + if (cache.TryGetChannel(channelId, out var channel)) + return channel; + return null; + } + + public static User GetUser(this IDiscordCache cache, ulong userId) + { + if (!cache.TryGetUser(userId, out var user)) + throw new KeyNotFoundException($"User {userId} not found in cache"); + return user; + } + + public static Role GetRole(this IDiscordCache cache, ulong roleId) + { + if (!cache.TryGetRole(roleId, out var role)) + throw new KeyNotFoundException($"User {roleId} not found in cache"); + return role; + } + } +} \ No newline at end of file diff --git a/Myriad/Extensions/ChannelExtensions.cs b/Myriad/Extensions/ChannelExtensions.cs index 99344138..0f04cb03 100644 --- a/Myriad/Extensions/ChannelExtensions.cs +++ b/Myriad/Extensions/ChannelExtensions.cs @@ -1,7 +1,9 @@ -namespace Myriad.Extensions +using Myriad.Types; + +namespace Myriad.Extensions { public static class ChannelExtensions { - + public static string Mention(this Channel channel) => $"<#{channel.Id}>"; } } \ No newline at end of file diff --git a/Myriad/Extensions/GuildExtensions.cs b/Myriad/Extensions/GuildExtensions.cs new file mode 100644 index 00000000..1e95b8bc --- /dev/null +++ b/Myriad/Extensions/GuildExtensions.cs @@ -0,0 +1,7 @@ +namespace Myriad.Extensions +{ + public static class GuildExtensions + { + + } +} \ No newline at end of file diff --git a/Myriad/Extensions/MessageExtensions.cs b/Myriad/Extensions/MessageExtensions.cs index ef999fc0..7393a9a2 100644 --- a/Myriad/Extensions/MessageExtensions.cs +++ b/Myriad/Extensions/MessageExtensions.cs @@ -1,7 +1,6 @@ namespace Myriad.Extensions { - public class MessageExtensions + public static class MessageExtensions { - } } \ No newline at end of file diff --git a/Myriad/Extensions/PermissionExtensions.cs b/Myriad/Extensions/PermissionExtensions.cs index 02fd3292..60f4f52b 100644 --- a/Myriad/Extensions/PermissionExtensions.cs +++ b/Myriad/Extensions/PermissionExtensions.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Myriad.Cache; using Myriad.Gateway; using Myriad.Types; @@ -9,17 +11,39 @@ namespace Myriad.Extensions { public static class PermissionExtensions { + public static PermissionSet PermissionsFor(this IDiscordCache cache, MessageCreateEvent message) => + PermissionsFor(cache, message.ChannelId, message.Author.Id, message.Member?.Roles); + + public static PermissionSet PermissionsFor(this IDiscordCache cache, ulong channelId, GuildMember member) => + PermissionsFor(cache, channelId, member.User.Id, member.Roles); + + public static PermissionSet PermissionsFor(this IDiscordCache cache, ulong channelId, ulong userId, GuildMemberPartial member) => + PermissionsFor(cache, channelId, userId, member.Roles); + + public static PermissionSet PermissionsFor(this IDiscordCache cache, ulong channelId, ulong userId, ICollection? userRoles) + { + var channel = cache.GetChannel(channelId); + if (channel.GuildId == null) + return PermissionSet.Dm; + + var guild = cache.GetGuild(channel.GuildId.Value); + return PermissionsFor(guild, channel, userId, userRoles); + } + public static PermissionSet EveryonePermissions(this Guild guild) => guild.Roles.FirstOrDefault(r => r.Id == guild.Id)?.Permissions ?? PermissionSet.Dm; public static PermissionSet PermissionsFor(Guild guild, Channel channel, MessageCreateEvent msg) => - PermissionsFor(guild, channel, msg.Author.Id, msg.Member!.Roles); + PermissionsFor(guild, channel, msg.Author.Id, msg.Member?.Roles); public static PermissionSet PermissionsFor(Guild guild, Channel channel, ulong userId, - ICollection roleIds) + ICollection? roleIds) { if (channel.Type == Channel.ChannelType.Dm) return PermissionSet.Dm; + + if (roleIds == null) + throw new ArgumentException($"User roles must be specified for guild channels"); var perms = GuildPermissions(guild, userId, roleIds); perms = ApplyChannelOverwrites(perms, channel, userId, roleIds); @@ -36,9 +60,6 @@ namespace Myriad.Extensions return perms; } - public static bool Has(this PermissionSet value, PermissionSet flag) => - (value & flag) == flag; - public static PermissionSet GuildPermissions(this Guild guild, ulong userId, ICollection roleIds) { if (guild.OwnerId == userId) @@ -51,7 +72,7 @@ namespace Myriad.Extensions perms |= role.Permissions; } - if (perms.Has(PermissionSet.Administrator)) + if (perms.HasFlag(PermissionSet.Administrator)) return PermissionSet.All; return perms; diff --git a/Myriad/Extensions/UserExtensions.cs b/Myriad/Extensions/UserExtensions.cs index 1f31b231..e4b1e5ef 100644 --- a/Myriad/Extensions/UserExtensions.cs +++ b/Myriad/Extensions/UserExtensions.cs @@ -4,6 +4,8 @@ namespace Myriad.Extensions { public static class UserExtensions { + public static string Mention(this User user) => $"<@{user.Id}>"; + public static string AvatarUrl(this User user) => $"https://cdn.discordapp.com/avatars/{user.Id}/{user.Avatar}.png"; } diff --git a/Myriad/Rest/Types/AllowedMentions.cs b/Myriad/Rest/Types/AllowedMentions.cs index 019c735d..d3ab3199 100644 --- a/Myriad/Rest/Types/AllowedMentions.cs +++ b/Myriad/Rest/Types/AllowedMentions.cs @@ -11,9 +11,9 @@ namespace Myriad.Rest.Types Everyone } - public List? Parse { get; set; } - public List? Users { get; set; } - public List? Roles { get; set; } + 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/Requests/MessageRequest.cs b/Myriad/Rest/Types/Requests/MessageRequest.cs index ae9625f7..72f018e5 100644 --- a/Myriad/Rest/Types/Requests/MessageRequest.cs +++ b/Myriad/Rest/Types/Requests/MessageRequest.cs @@ -8,6 +8,6 @@ namespace Myriad.Rest.Types.Requests public object? Nonce { get; set; } public bool Tts { get; set; } public AllowedMentions AllowedMentions { get; set; } - public Embed? Embeds { get; set; } + public Embed? Embed { get; set; } } } \ No newline at end of file diff --git a/Myriad/Types/Channel.cs b/Myriad/Types/Channel.cs index 72e1854c..2ac13cc6 100644 --- a/Myriad/Types/Channel.cs +++ b/Myriad/Types/Channel.cs @@ -20,7 +20,7 @@ public string? Name { get; init; } public string? Topic { get; init; } public bool? Nsfw { get; init; } - public long? ParentId { get; init; } + public ulong? ParentId { get; init; } public Overwrite[]? PermissionOverwrites { get; init; } public record Overwrite diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index ad96ecd5..a5d8c29a 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -8,14 +8,17 @@ using Autofac; using DSharpPlus; using DSharpPlus.Entities; +using DSharpPlus.Net; +using Myriad.Cache; using Myriad.Extensions; using Myriad.Gateway; +using Myriad.Rest.Types.Requests; using Myriad.Types; using PluralKit.Core; -using Permissions = DSharpPlus.Permissions; +using DiscordApiClient = Myriad.Rest.DiscordApiClient; namespace PluralKit.Bot { @@ -24,6 +27,7 @@ namespace PluralKit.Bot private readonly ILifetimeScope _provider; private readonly DiscordRestClient _rest; + private readonly DiscordApiClient _newRest; private readonly DiscordShardedClient _client; private readonly DiscordClient _shard = null; private readonly Shard _shardNew; @@ -42,6 +46,7 @@ namespace PluralKit.Bot private readonly PKSystem _senderSystem; private readonly IMetrics _metrics; private readonly CommandMessageService _commandMessageService; + private readonly IDiscordCache _cache; private Command _currentCommand; @@ -57,24 +62,25 @@ namespace PluralKit.Bot _senderSystem = senderSystem; _messageContext = messageContext; _botMember = botMember; + _cache = provider.Resolve(); _db = provider.Resolve(); _repo = provider.Resolve(); _metrics = provider.Resolve(); _provider = provider; _commandMessageService = provider.Resolve(); _parameters = new Parameters(message.Content.Substring(commandParseOffset)); + _newRest = provider.Resolve(); - _botPermissions = message.GuildId != null - ? PermissionExtensions.PermissionsFor(guild!, channel, shard.User?.Id ?? default, botMember!.Roles) - : PermissionSet.Dm; - _userPermissions = message.GuildId != null - ? PermissionExtensions.PermissionsFor(guild!, channel, message.Author.Id, message.Member!.Roles) - : PermissionSet.Dm; + _botPermissions = _cache.PermissionsFor(message.ChannelId, shard.User!.Id, botMember!); + _userPermissions = _cache.PermissionsFor(message); } + public IDiscordCache Cache => _cache; + public DiscordUser Author => _message.Author; public DiscordChannel Channel => _message.Channel; public Channel ChannelNew => _channel; + public User AuthorNew => _messageNew.Author; public DiscordMessage Message => _message; public Message MessageNew => _messageNew; public DiscordGuild Guild => _message.Channel.Guild; @@ -95,24 +101,44 @@ namespace PluralKit.Bot internal IDatabase Database => _db; internal ModelRepository Repository => _repo; - public async Task Reply(string text = null, DiscordEmbed embed = null, IEnumerable mentions = null) + public Task Reply(string text, DiscordEmbed embed, + IEnumerable? mentions = null) { - if (!this.BotHasAllPermissions(Permissions.SendMessages)) + return Reply(text, (DiscordEmbed) null, mentions); + } + + public Task Reply(DiscordEmbed embed, + IEnumerable? mentions = null) + { + return Reply(null, (DiscordEmbed) null, mentions); + } + + public async Task Reply(string text = null, Embed embed = null, IEnumerable? mentions = null) + { + if (!BotPermissions.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 && !this.BotHasAllPermissions(Permissions.EmbedLinks)) + if (embed != null && !BotPermissions.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 Channel.SendMessageFixedAsync(text, embed: embed, mentions: mentions); + + var msg = await _newRest.CreateMessage(_channel.Id, new MessageRequest + { + Content = text, + Embed = embed + }); + // TODO: embeds/mentions + // var msg = await Channel.SendMessageFixedAsync(text, embed: embed, mentions: mentions); if (embed != null) { // Sensitive information that might want to be deleted by :x: reaction is typically in an embed format (member cards, for example) // This may need to be changed at some point but works well enough for now - await _commandMessageService.RegisterMessage(msg.Id, Author.Id); + await _commandMessageService.RegisterMessage(msg.Id, AuthorNew.Id); } - return msg; + // return msg; + return null; } public async Task Execute(Command commandDef, Func handler) diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs index 5ae896bb..53ae3015 100644 --- a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs @@ -1,5 +1,7 @@ using DSharpPlus; +using Myriad.Types; + using PluralKit.Core; namespace PluralKit.Bot @@ -8,7 +10,7 @@ namespace PluralKit.Bot { public static Context CheckGuildContext(this Context ctx) { - if (ctx.Channel.Guild != null) return ctx; + if (ctx.ChannelNew.GuildId != null) return ctx; throw new PKError("This command can not be run in a DM."); } @@ -46,12 +48,9 @@ namespace PluralKit.Bot return ctx; } - public static Context CheckAuthorPermission(this Context ctx, Permissions neededPerms, string permissionName) + public static Context CheckAuthorPermission(this Context ctx, PermissionSet neededPerms, string permissionName) { - // TODO: can we always assume Author is a DiscordMember? I would think so, given they always come from a - // message received event... - var hasPerms = ctx.Channel.PermissionsInSync(ctx.Author); - if ((hasPerms & neededPerms) != neededPerms) + if ((ctx.UserPermissions & neededPerms) != neededPerms) throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command."); return ctx; } diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 32dd11c0..97e2efa4 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -3,6 +3,8 @@ using DSharpPlus; using DSharpPlus.Entities; +using Myriad.Types; + using PluralKit.Bot.Utils; using PluralKit.Core; @@ -153,13 +155,16 @@ namespace PluralKit.Bot return $"Group not found. Note that a group ID is 5 characters long."; } - public static async Task MatchChannel(this Context ctx) + public static async Task MatchChannel(this Context ctx) { if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id)) return null; + + if (!ctx.Cache.TryGetChannel(id, out var channel)) + return null; - var channel = await ctx.Shard.GetChannel(id); - if (channel == null || !(channel.Type == ChannelType.Text || channel.Type == ChannelType.News)) return null; + if (!(channel.Type == Channel.ChannelType.GuildText || channel.Type == Channel.ChannelType.GuildText)) + return null; ctx.PopArgument(); return channel; diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 60f2426f..b29d3816 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -2,7 +2,6 @@ using System.Linq; using System.Threading.Tasks; using DSharpPlus; -using DSharpPlus.Exceptions; using Humanizer; diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 786779b6..06d5d9c7 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -25,7 +25,7 @@ namespace PluralKit.Bot 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}**)."); + await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member will now use the global avatar in this server (**{ctx.GuildNew.Name}**)."); else await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member now has no avatar."); } @@ -55,7 +55,7 @@ namespace PluralKit.Bot 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 field = location == AvatarLocation.Server ? $"server avatar (for {ctx.GuildNew.Name})" : "avatar"; var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar"; var eb = new DiscordEmbedBuilder() @@ -69,14 +69,14 @@ namespace PluralKit.Bot public async Task ServerAvatar(Context ctx, PKMember target) { ctx.CheckGuildContext(); - var guildData = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); + var guildData = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); await AvatarCommandTree(AvatarLocation.Server, ctx, target, guildData); } public async Task Avatar(Context ctx, PKMember target) { - var guildData = ctx.Guild != null ? - await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)) + var guildData = ctx.GuildNew != null ? + await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)) : null; await AvatarCommandTree(AvatarLocation.Member, ctx, target, guildData); @@ -119,8 +119,8 @@ namespace PluralKit.Bot 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.", + AvatarLocation.Server => $" This avatar will now be used when proxying in this server (**{ctx.GuildNew.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.GuildNew.Name}**), and thus changing the global avatar will have no effect here.", _ => "" }; @@ -145,7 +145,7 @@ namespace PluralKit.Bot { case AvatarLocation.Server: var serverPatch = new MemberGuildPatch { AvatarUrl = url }; - return _db.Execute(c => _repo.UpsertMemberGuild(c, target.Id, ctx.Guild.Id, serverPatch)); + return _db.Execute(c => _repo.UpsertMemberGuild(c, target.Id, ctx.GuildNew.Id, serverPatch)); case AvatarLocation.Member: var memberPatch = new MemberPatch { AvatarUrl = url }; return _db.Execute(c => _repo.UpdateMember(c, target.Id, memberPatch)); diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 43d8fa82..710269d6 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -2,9 +2,6 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using System; - -using Dapper; - using DSharpPlus.Entities; using NodaTime; @@ -49,11 +46,11 @@ namespace PluralKit.Bot 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) + if (ctx.GuildNew != null) { - var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); + var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.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."); + await ctx.Reply($"{Emojis.Note} Note that this member has a server name set ({memberGuildConfig.DisplayName}) in this server ({ctx.GuildNew.Name}), and will be proxied using that name here."); } } @@ -229,8 +226,8 @@ namespace PluralKit.Bot var lcx = ctx.LookupContextFor(target); MemberGuildSettings memberGuildConfig = null; - if (ctx.Guild != null) - memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); + if (ctx.GuildNew != null) + memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); var eb = new DiscordEmbedBuilder().WithTitle($"Member names") .WithFooter($"Member ID: {target.Hid} | Active name in bold. Server name overrides display name, which overrides base name."); @@ -248,12 +245,12 @@ namespace PluralKit.Bot eb.AddField("Display Name", target.DisplayName ?? "*(none)*"); } - if (ctx.Guild != null) + if (ctx.GuildNew != null) { if (memberGuildConfig?.DisplayName != null) - eb.AddField($"Server Name (in {ctx.Guild.Name})", $"**{memberGuildConfig.DisplayName}**"); + eb.AddField($"Server Name (in {ctx.GuildNew.Name})", $"**{memberGuildConfig.DisplayName}**"); else - eb.AddField($"Server Name (in {ctx.Guild.Name})", memberGuildConfig?.DisplayName ?? "*(none)*"); + eb.AddField($"Server Name (in {ctx.GuildNew.Name})", memberGuildConfig?.DisplayName ?? "*(none)*"); } return eb; @@ -264,11 +261,11 @@ namespace PluralKit.Bot async Task PrintSuccess(string text) { var successStr = text; - if (ctx.Guild != null) + if (ctx.GuildNew != null) { - var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); + var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.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."; + successStr += $" However, this member has a server name set in this server ({ctx.GuildNew.Name}), and will be proxied using that name, \"{memberGuildConfig.DisplayName}\", here."; } await ctx.Reply(successStr); @@ -313,12 +310,12 @@ namespace PluralKit.Bot ctx.CheckOwnMember(target); var patch = new MemberGuildPatch {DisplayName = null}; - await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.Guild.Id, patch)); + await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.GuildNew.Id, patch)); if (target.DisplayName != null) - await ctx.Reply($"{Emojis.Success} Member server name cleared. This member will now be proxied using their global display name \"{target.DisplayName}\" in this server ({ctx.Guild.Name})."); + 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.GuildNew.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})."); + 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.GuildNew.Name})."); } else if (!ctx.HasNext()) { @@ -335,9 +332,9 @@ namespace PluralKit.Bot var newServerName = ctx.RemainderOrNull(); var patch = new MemberGuildPatch {DisplayName = newServerName}; - await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.Guild.Id, patch)); + await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.GuildNew.Id, patch)); - await ctx.Reply($"{Emojis.Success} Member server name changed. This member will now be proxied using the name \"{newServerName}\" in this server ({ctx.Guild.Name})."); + await ctx.Reply($"{Emojis.Success} Member server name changed. This member will now be proxied using the name \"{newServerName}\" in this server ({ctx.GuildNew.Name})."); } } @@ -417,8 +414,8 @@ namespace PluralKit.Bot // Get guild settings (mostly for warnings and such) MemberGuildSettings guildSettings = null; - if (ctx.Guild != null) - guildSettings = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); + if (ctx.GuildNew != null) + guildSettings = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); async Task SetAll(PrivacyLevel level) { diff --git a/PluralKit.Bot/Commands/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index 86bbd4ff..507a2ceb 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -6,29 +6,37 @@ using System.Threading.Tasks; using DSharpPlus; using DSharpPlus.Entities; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Types; + using PluralKit.Core; +using Permissions = DSharpPlus.Permissions; + namespace PluralKit.Bot { public class ServerConfig { private readonly IDatabase _db; private readonly ModelRepository _repo; + private readonly IDiscordCache _cache; private readonly LoggerCleanService _cleanService; - public ServerConfig(LoggerCleanService cleanService, IDatabase db, ModelRepository repo) + public ServerConfig(LoggerCleanService cleanService, IDatabase db, ModelRepository repo, IDiscordCache cache) { _cleanService = cleanService; _db = db; _repo = repo; + _cache = cache; } public async Task SetLogChannel(Context ctx) { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); if (await ctx.MatchClear("the server log channel")) { - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, new GuildPatch {LogChannel = null})); + await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.GuildNew.Id, new GuildPatch {LogChannel = null})); await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared."); return; } @@ -36,36 +44,36 @@ namespace PluralKit.Bot if (!ctx.HasNext()) throw new PKSyntaxError("You must pass a #channel to set, or `clear` to clear it."); - DiscordChannel channel = null; + 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 == null || channel.GuildId != ctx.GuildNew.Id) throw Errors.ChannelNotFound(channelString); var patch = new GuildPatch {LogChannel = channel.Id}; - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, patch)); + await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.GuildNew.Id, patch)); await ctx.Reply($"{Emojis.Success} Proxy logging channel set to #{channel.Name}."); } public async Task SetLogEnabled(Context ctx, bool enable) { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - var affectedChannels = new List(); + var affectedChannels = new List(); if (ctx.Match("all")) - affectedChannels = (await ctx.Guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text).ToList(); + affectedChannels = _cache.GetGuildChannels(ctx.GuildNew.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); + if (channel == null || channel.GuildId != ctx.GuildNew.Id) throw Errors.ChannelNotFound(channelString); affectedChannels.Add(channel); } ulong? logChannel = null; await using (var conn = await _db.Obtain()) { - var config = await _repo.GetGuild(conn, ctx.Guild.Id); + var config = await _repo.GetGuild(conn, ctx.GuildNew.Id); logChannel = config.LogChannel; var blacklist = config.LogBlacklist.ToHashSet(); if (enable) @@ -74,7 +82,7 @@ namespace PluralKit.Bot blacklist.UnionWith(affectedChannels.Select(c => c.Id)); var patch = new GuildPatch {LogBlacklist = blacklist.ToArray()}; - await _repo.UpsertGuild(conn, ctx.Guild.Id, patch); + await _repo.UpsertGuild(conn, ctx.GuildNew.Id, patch); } await ctx.Reply( @@ -84,13 +92,13 @@ namespace PluralKit.Bot public async Task ShowBlacklisted(Context ctx) { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - var blacklist = await _db.Execute(c => _repo.GetGuild(c, ctx.Guild.Id)); + var blacklist = await _db.Execute(c => _repo.GetGuild(c, ctx.GuildNew.Id)); // Resolve all channels from the cache and order by position var channels = blacklist.Blacklist - .Select(id => ctx.Guild.GetChannel(id)) + .Select(id => _cache.GetChannelOrNull(id)) .Where(c => c != null) .OrderBy(c => c.Position) .ToList(); @@ -102,26 +110,29 @@ namespace PluralKit.Bot } await ctx.Paginate(channels.ToAsyncEnumerable(), channels.Count, 25, - $"Blacklisted channels for {ctx.Guild.Name}", + $"Blacklisted channels for {ctx.GuildNew.Name}", (eb, l) => { - DiscordChannel lastCategory = null; + string CategoryName(ulong? id) => + id != null ? _cache.GetChannel(id.Value).Name : "(no category)"; + + ulong? lastCategory = null; var fieldValue = new StringBuilder(); foreach (var channel in l) { - if (lastCategory != channel.Parent && fieldValue.Length > 0) + if (lastCategory != channel!.ParentId && fieldValue.Length > 0) { - eb.AddField(lastCategory?.Name ?? "(no category)", fieldValue.ToString()); + eb.AddField(CategoryName(lastCategory), fieldValue.ToString()); fieldValue.Clear(); } else fieldValue.Append("\n"); - fieldValue.Append(channel.Mention); - lastCategory = channel.Parent; + fieldValue.Append(channel.Mention()); + lastCategory = channel.ParentId; } - eb.AddField(lastCategory?.Name ?? "(no category)", fieldValue.ToString()); + eb.AddField(CategoryName(lastCategory), fieldValue.ToString()); return Task.CompletedTask; }); @@ -129,23 +140,23 @@ namespace PluralKit.Bot public async Task SetBlacklisted(Context ctx, bool shouldAdd) { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - var affectedChannels = new List(); + var affectedChannels = new List(); if (ctx.Match("all")) - affectedChannels = (await ctx.Guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text).ToList(); + affectedChannels = _cache.GetGuildChannels(ctx.GuildNew.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); + if (channel == null || channel.GuildId != ctx.GuildNew.Id) throw Errors.ChannelNotFound(channelString); affectedChannels.Add(channel); } await using (var conn = await _db.Obtain()) { - var guild = await _repo.GetGuild(conn, ctx.Guild.Id); + var guild = await _repo.GetGuild(conn, ctx.GuildNew.Id); var blacklist = guild.Blacklist.ToHashSet(); if (shouldAdd) blacklist.UnionWith(affectedChannels.Select(c => c.Id)); @@ -153,7 +164,7 @@ namespace PluralKit.Bot blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); var patch = new GuildPatch {Blacklist = blacklist.ToArray()}; - await _repo.UpsertGuild(conn, ctx.Guild.Id, patch); + await _repo.UpsertGuild(conn, ctx.GuildNew.Id, patch); } await ctx.Reply($"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist."); @@ -161,7 +172,7 @@ namespace PluralKit.Bot public async Task SetLogCleanup(Context ctx) { - ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); var botList = string.Join(", ", _cleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant())); @@ -176,7 +187,7 @@ namespace PluralKit.Bot .WithTitle("Log cleanup settings") .AddField("Supported bots", botList); - var guildCfg = await _db.Execute(c => _repo.GetGuild(c, ctx.Guild.Id)); + var guildCfg = await _db.Execute(c => _repo.GetGuild(c, ctx.GuildNew.Id)); if (guildCfg.LogCleanupEnabled) eb.WithDescription("Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`."); else @@ -186,7 +197,7 @@ namespace PluralKit.Bot } var patch = new GuildPatch {LogCleanupEnabled = newValue}; - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, patch)); + await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.GuildNew.Id, patch)); if (newValue) await ctx.Reply($"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts."); diff --git a/PluralKit.Bot/Commands/Switch.cs b/PluralKit.Bot/Commands/Switch.cs index a5094e21..9486ceba 100644 --- a/PluralKit.Bot/Commands/Switch.cs +++ b/PluralKit.Bot/Commands/Switch.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using DSharpPlus.Entities; - using NodaTime; using NodaTime.TimeZones; diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index a4f641af..ab95b105 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -2,9 +2,6 @@ using System; using System.Linq; using System.Threading.Tasks; -using Dapper; - -using DSharpPlus; using DSharpPlus.Entities; using NodaTime; @@ -13,8 +10,6 @@ using NodaTime.TimeZones; using PluralKit.Core; -using Sentry.Protocol; - namespace PluralKit.Bot { public class SystemEdit @@ -196,7 +191,7 @@ namespace PluralKit.Bot public async Task SystemProxy(Context ctx) { ctx.CheckSystem().CheckGuildContext(); - var gs = await _db.Execute(c => _repo.GetSystemGuild(c, ctx.Guild.Id, ctx.System.Id)); + var gs = await _db.Execute(c => _repo.GetSystemGuild(c, ctx.GuildNew.Id, ctx.System.Id)); bool newValue; if (ctx.Match("on", "enabled", "true", "yes")) newValue = true; @@ -212,12 +207,12 @@ namespace PluralKit.Bot } var patch = new SystemGuildPatch {ProxyEnabled = newValue}; - await _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.Guild.Id, patch)); + await _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.GuildNew.Id, patch)); if (newValue) - await ctx.Reply($"Message proxying in this server ({ctx.Guild.Name.EscapeMarkdown()}) is now **enabled** for your system."); + await ctx.Reply($"Message proxying in this server ({ctx.GuildNew.Name.EscapeMarkdown()}) is now **enabled** for your system."); else - await ctx.Reply($"Message proxying in this server ({ctx.Guild.Name.EscapeMarkdown()}) is now **disabled** for your system."); + await ctx.Reply($"Message proxying in this server ({ctx.GuildNew.Name.EscapeMarkdown()}) is now **disabled** for your system."); } public async Task SystemTimezone(Context ctx) diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 9dfc15da..1f22b88a 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index 7d7db63d..493e5bda 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -1,8 +1,6 @@ using System.Text; using System.Threading.Tasks; -using NodaTime; - using PluralKit.Core; namespace PluralKit.Bot diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index 95cc9995..0a72c424 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -61,8 +61,8 @@ namespace PluralKit.Bot if (evt.Type != Message.MessageType.Default) 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 guild = evt.GuildId != null ? _cache.GetGuild(evt.GuildId.Value) : null; + var channel = _cache.GetChannel(evt.ChannelId); // Log metrics and message info _metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived); @@ -89,8 +89,8 @@ namespace PluralKit.Bot private async ValueTask TryHandleLogClean(MessageCreateEvent evt, MessageContext ctx) { - var channel = await _cache.GetChannel(evt.ChannelId); - if (!evt.Author.Bot || channel!.Type != Channel.ChannelType.GuildText || + var channel = _cache.GetChannel(evt.ChannelId); + if (!evt.Author.Bot || channel.Type != Channel.ChannelType.GuildText || !ctx.LogCleanupEnabled) return false; await _loggerClean.HandleLoggerBotCleanup(evt); diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index f9ca05bb..0631d8b1 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -5,9 +5,13 @@ using System.Threading.Tasks; using DSharpPlus; using DSharpPlus.Entities; -using DSharpPlus.Exceptions; using Humanizer; + +using Myriad.Cache; +using Myriad.Rest; +using Myriad.Types; + using NodaTime; using PluralKit.Core; @@ -18,54 +22,79 @@ namespace PluralKit.Bot { private readonly IDatabase _db; private readonly ModelRepository _repo; private readonly DiscordShardedClient _client; + private readonly IDiscordCache _cache; + private readonly DiscordApiClient _rest; - public EmbedService(DiscordShardedClient client, IDatabase db, ModelRepository repo) + public EmbedService(DiscordShardedClient client, IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest) { _client = client; _db = db; _repo = repo; + _cache = cache; + _rest = rest; + } + + private Task<(ulong Id, User? User)[]> GetUsers(IEnumerable ids) + { + async Task<(ulong Id, User? User)> Inner(ulong id) + { + if (_cache.TryGetUser(id, out var cachedUser)) + return (id, cachedUser); + + var user = await _rest.GetUser(id); + if (user == null) + return (id, null); + // todo: move to "GetUserCached" helper + await _cache.SaveUser(user); + return (id, user); + } + + return Task.WhenAll(ids.Select(Inner)); } - public async Task CreateSystemEmbed(Context cctx, PKSystem system, LookupContext ctx) + public async Task CreateSystemEmbed(Context cctx, PKSystem system, LookupContext ctx) { await using var conn = await _db.Obtain(); // Fetch/render info for all accounts simultaneously var accounts = await _repo.GetSystemAccounts(conn, system.Id); - var users = await Task.WhenAll(accounts.Select(async uid => (await cctx.Shard.GetUser(uid))?.NameAndMention() ?? $"(deleted account {uid})")); + var users = (await GetUsers(accounts)).Select(x => x.User?.NameAndMention() ?? $"(deleted account {x.Id})"); var memberCount = cctx.MatchPrivateFlag(ctx) ? await _repo.GetSystemMemberCount(conn, system.Id, PrivacyLevel.Public) : await _repo.GetSystemMemberCount(conn, system.Id); - var eb = new DiscordEmbedBuilder() - .WithColor(DiscordUtils.Gray) - .WithTitle(system.Name ?? null) - .WithThumbnail(system.AvatarUrl) - .WithFooter($"System ID: {system.Hid} | Created on {system.Created.FormatZoned(system)}"); + var embed = new Embed + { + Title = system.Name, + Thumbnail = new(system.AvatarUrl), + Footer = new($"System ID: {system.Hid} | Created on {system.Created.FormatZoned(system)}"), + Color = (uint?) DiscordUtils.Gray.Value + }; + var fields = new List(); var latestSwitch = await _repo.GetLatestSwitch(conn, system.Id); if (latestSwitch != null && system.FrontPrivacy.CanAccess(ctx)) { var switchMembers = await _repo.GetSwitchMembers(conn, latestSwitch.Id).ToListAsync(); - if (switchMembers.Count > 0) - eb.AddField("Fronter".ToQuantity(switchMembers.Count(), ShowQuantityAs.None), - string.Join(", ", switchMembers.Select(m => m.NameFor(ctx)))); + if (switchMembers.Count > 0) + fields.Add(new("Fronter".ToQuantity(switchMembers.Count, ShowQuantityAs.None), string.Join(", ", switchMembers.Select(m => m.NameFor(ctx))))); } - if (system.Tag != null) eb.AddField("Tag", system.Tag.EscapeMarkdown()); - eb.AddField("Linked accounts", string.Join("\n", users).Truncate(1000), true); + if (system.Tag != null) + fields.Add(new("Tag", system.Tag.EscapeMarkdown())); + fields.Add(new("Linked accounts", string.Join("\n", users).Truncate(1000), true)); if (system.MemberListPrivacy.CanAccess(ctx)) { if (memberCount > 0) - eb.AddField($"Members ({memberCount})", $"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)", true); + fields.Add(new($"Members ({memberCount})", $"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)", true)); else - eb.AddField($"Members ({memberCount})", "Add one with `pk;member new`!", true); + fields.Add(new($"Members ({memberCount})", "Add one with `pk;member new`!", true)); } if (system.DescriptionFor(ctx) is { } desc) - eb.AddField("Description", desc.NormalizeLineEndSpacing().Truncate(1024), false); + fields.Add(new("Description", desc.NormalizeLineEndSpacing().Truncate(1024), false)); - return eb.Build(); + return embed with { Fields = fields.ToArray() }; } public DiscordEmbed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordUser sender, string content, DiscordChannel channel) { diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index 241edde3..c4bad54a 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -60,18 +60,16 @@ namespace PluralKit.Bot { private async Task FindLogChannel(ulong guildId, ulong channelId) { // TODO: fetch it directly on cache miss? - var channel = await _cache.GetChannel(channelId); + if (_cache.TryGetChannel(channelId, out var channel)) + return channel; - if (channel == null) - { - // Channel doesn't exist or we don't have permission to access it, let's remove it from the database too - _logger.Warning("Attempted to fetch missing log channel {LogChannel} for guild {Guild}, removing from database", channelId, guildId); - await using var conn = await _db.Obtain(); - await conn.ExecuteAsync("update servers set log_channel = null where id = @Guild", - new {Guild = guildId}); - } + // Channel doesn't exist or we don't have permission to access it, let's remove it from the database too + _logger.Warning("Attempted to fetch missing log channel {LogChannel} for guild {Guild}, removing from database", channelId, guildId); + await using var conn = await _db.Obtain(); + await conn.ExecuteAsync("update servers set log_channel = null where id = @Guild", + new {Guild = guildId}); - return channel; + return null; } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/WebhookExecutorService.cs b/PluralKit.Bot/Services/WebhookExecutorService.cs index 61efabbe..005f2b45 100644 --- a/PluralKit.Bot/Services/WebhookExecutorService.cs +++ b/PluralKit.Bot/Services/WebhookExecutorService.cs @@ -9,6 +9,7 @@ using App.Metrics; using Humanizer; using Myriad.Cache; +using Myriad.Extensions; using Myriad.Rest; using Myriad.Rest.Types; using Myriad.Rest.Types.Requests; @@ -77,20 +78,22 @@ namespace PluralKit.Bot private async Task ExecuteWebhookInner(Webhook webhook, ProxyRequest req, bool hasRetried = false) { - var guild = await _cache.GetGuild(req.GuildId)!; + var guild = _cache.GetGuild(req.GuildId); var content = req.Content.Truncate(2000); + var allowedMentions = content.ParseMentions(); + if (!req.AllowEveryone) + allowedMentions = allowedMentions.RemoveUnmentionableRoles(guild); + var webhookReq = new ExecuteWebhookRequest { Username = FixClyde(req.Name).Truncate(80), Content = content, - AllowedMentions = null, // todo + AllowedMentions = allowedMentions, AvatarUrl = !string.IsNullOrWhiteSpace(req.AvatarUrl) ? req.AvatarUrl : null, Embeds = req.Embeds }; - // dwb.AddMentions(content.ParseAllMentions(guild, req.AllowEveryone)); - MultipartFile[] files = null; var attachmentChunks = ChunkAttachmentsOrThrow(req.Attachments, 8 * 1024 * 1024); if (attachmentChunks.Count > 0) diff --git a/PluralKit.Bot/Utils/ContextUtils.cs b/PluralKit.Bot/Utils/ContextUtils.cs index 4733bd99..67bb369d 100644 --- a/PluralKit.Bot/Utils/ContextUtils.cs +++ b/PluralKit.Bot/Utils/ContextUtils.cs @@ -11,10 +11,14 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using DSharpPlus.Exceptions; +using Myriad.Types; + using NodaTime; using PluralKit.Core; +using Permissions = DSharpPlus.Permissions; + namespace PluralKit.Bot { public static class ContextUtils { public static async Task ConfirmClear(this Context ctx, string toClear) @@ -149,7 +153,8 @@ namespace PluralKit.Bot { if (currentPage < 0) currentPage += pageCount; // If we can, remove the user's reaction (so they can press again quickly) - if (ctx.BotHasAllPermissions(Permissions.ManageMessages)) await msg.DeleteReactionAsync(reaction.Emoji, reaction.User); + if (ctx.BotPermissions.HasFlag(PermissionSet.ManageMessages)) + await msg.DeleteReactionAsync(reaction.Emoji, reaction.User); // Edit the embed with the new page var embed = await MakeEmbedForPage(currentPage); @@ -159,7 +164,8 @@ namespace PluralKit.Bot { // "escape hatch", clean up as if we hit X } - if (ctx.BotHasAllPermissions(Permissions.ManageMessages)) await msg.DeleteAllReactionsAsync(); + if (ctx.BotPermissions.HasFlag(PermissionSet.ManageMessages)) + await msg.DeleteAllReactionsAsync(); } // If we get a "NotFound" error, the message has been deleted and thus not our problem catch (NotFoundException) { } @@ -245,12 +251,7 @@ namespace PluralKit.Bot { return items[Array.IndexOf(indicators, reaction.Emoji.Name)]; } } - - public static Permissions BotPermissions(this Context ctx) => ctx.Channel.BotPermissions(); - - public static bool BotHasAllPermissions(this Context ctx, Permissions permission) => - ctx.Channel.BotHasAllPermissions(permission); - + public static async Task BusyIndicator(this Context ctx, Func f, string emoji = "\u23f3" /* hourglass */) { await ctx.BusyIndicator(async () => @@ -265,8 +266,8 @@ namespace PluralKit.Bot { var task = f(); // If we don't have permission to add reactions, don't bother, and just await the task normally. - var neededPermissions = Permissions.AddReactions | Permissions.ReadMessageHistory; - if ((ctx.BotPermissions() & neededPermissions) != neededPermissions) return await task; + var neededPermissions = PermissionSet.AddReactions | PermissionSet.ReadMessageHistory; + if ((ctx.BotPermissions & neededPermissions) != neededPermissions) return await task; try { diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index 903f2b31..ee3e391d 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -12,6 +12,8 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using DSharpPlus.Exceptions; +using Myriad.Extensions; +using Myriad.Rest.Types; using Myriad.Types; using NodaTime; @@ -50,6 +52,11 @@ namespace PluralKit.Bot { return $"{user.Username}#{user.Discriminator} ({user.Mention})"; } + + public static string NameAndMention(this User user) + { + return $"{user.Username}#{user.Discriminator} ({user.Mention()})"; + } // We funnel all "permissions from DiscordMember" calls through here // This way we can ensure we do the read permission correction everywhere @@ -74,20 +81,7 @@ namespace PluralKit.Bot var invalidRoleIds = roleIdCache.Where(x => !currentRoleIds.Contains(x)).ToList(); roleIdCache.RemoveAll(x => invalidRoleIds.Contains(x)); } - - public static async Task PermissionsIn(this DiscordChannel channel, DiscordUser user) - { - // Just delegates to PermissionsInSync, but handles the case of a non-member User in a guild properly - // This is a separate method because it requires an async call - if (channel.Guild != null && !(user is DiscordMember)) - { - var member = await channel.Guild.GetMember(user.Id); - if (member != null) - return PermissionsInSync(channel, member); - } - - return PermissionsInSync(channel, user); - } + // Same as PermissionsIn, but always synchronous. DiscordUser must be a DiscordMember if channel is in guild. public static Permissions PermissionsInSync(this DiscordChannel channel, DiscordUser user) @@ -194,23 +188,27 @@ namespace PluralKit.Bot return false; } - public static IEnumerable ParseAllMentions(this string input, Guild guild, bool allowEveryone = false) + public static AllowedMentions ParseMentions(this string input) { - var mentions = new List(); - mentions.AddRange(USER_MENTION.Matches(input) - .Select(x => new UserMention(ulong.Parse(x.Groups[1].Value)) as IMention)); + var users = USER_MENTION.Matches(input).Select(x => ulong.Parse(x.Groups[1].Value)); + var roles = ROLE_MENTION.Matches(input).Select(x => ulong.Parse(x.Groups[1].Value)); + var everyone = EVERYONE_HERE_MENTION.IsMatch(input); + + return new AllowedMentions + { + Users = users.ToArray(), + Roles = roles.ToArray(), + Parse = everyone ? new[] {AllowedMentions.ParseType.Everyone} : null + }; + } - // Only allow role mentions through where the role is actually listed as *mentionable* - // (ie. any user can @ them, regardless of permissions) - // Still let the allowEveryone flag override this though (privileged users can @ *any* role) - // Original fix by Gwen - mentions.AddRange(ROLE_MENTION.Matches(input) - .Select(x => ulong.Parse(x.Groups[1].Value)) - .Where(x => allowEveryone || guild != null && (guild.Roles.FirstOrDefault(g => g.Id == x)?.Mentionable ?? false)) - .Select(x => new RoleMention(x) as IMention)); - if (EVERYONE_HERE_MENTION.IsMatch(input) && allowEveryone) - mentions.Add(new EveryoneMention()); - return mentions; + public static AllowedMentions RemoveUnmentionableRoles(this AllowedMentions mentions, Guild guild) + { + return mentions with { + Roles = mentions.Roles + ?.Where(id => guild.Roles.FirstOrDefault(r => r.Id == id)?.Mentionable == true) + .ToArray() + }; } public static string EscapeMarkdown(this string input) From f6fb8204bb314d2b824fbbf1cee0ee260c84b757 Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 23 Dec 2020 02:19:02 +0100 Subject: [PATCH 03/26] Add embed builder, some more ported classes --- Myriad/Builders/EmbedBuilder.cs | 86 +++++++++++ Myriad/Extensions/MessageExtensions.cs | 10 +- Myriad/Rest/DiscordApiClient.cs | 6 +- Myriad/Types/Embed.cs | 4 +- PluralKit.Bot/Bot.cs | 14 +- PluralKit.Bot/CommandSystem/Context.cs | 6 +- PluralKit.Bot/Commands/CommandTree.cs | 10 -- PluralKit.Bot/Handlers/MessageCreated.cs | 5 +- PluralKit.Bot/Handlers/MessageDeleted.cs | 9 +- PluralKit.Bot/Handlers/ReactionAdded.cs | 120 +++++++------- PluralKit.Bot/Services/EmbedService.cs | 146 +++++++++--------- PluralKit.Bot/Services/ErrorMessageService.cs | 42 +++-- PluralKit.Bot/Services/LogChannelService.cs | 36 +++-- 13 files changed, 305 insertions(+), 189 deletions(-) create mode 100644 Myriad/Builders/EmbedBuilder.cs diff --git a/Myriad/Builders/EmbedBuilder.cs b/Myriad/Builders/EmbedBuilder.cs new file mode 100644 index 00000000..ecfc3524 --- /dev/null +++ b/Myriad/Builders/EmbedBuilder.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; + +using Myriad.Types; + +namespace Myriad.Builders +{ + public class EmbedBuilder + { + 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() }; + } +} \ No newline at end of file diff --git a/Myriad/Extensions/MessageExtensions.cs b/Myriad/Extensions/MessageExtensions.cs index 7393a9a2..60adb532 100644 --- a/Myriad/Extensions/MessageExtensions.cs +++ b/Myriad/Extensions/MessageExtensions.cs @@ -1,6 +1,14 @@ -namespace Myriad.Extensions +using Myriad.Gateway; +using Myriad.Types; + +namespace Myriad.Extensions { public static class MessageExtensions { + public static string JumpLink(this Message msg) => + $"https://discord.com/channels/{msg.GuildId}/{msg.ChannelId}/{msg.Id}"; + + public static string JumpLink(this MessageReactionAddEvent msg) => + $"https://discord.com/channels/{msg.GuildId}/{msg.ChannelId}/{msg.MessageId}"; } } \ No newline at end of file diff --git a/Myriad/Rest/DiscordApiClient.cs b/Myriad/Rest/DiscordApiClient.cs index 27588b51..71813481 100644 --- a/Myriad/Rest/DiscordApiClient.cs +++ b/Myriad/Rest/DiscordApiClient.cs @@ -39,6 +39,10 @@ namespace Myriad.Rest 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) => _client.Post($"/channels/{channelId}/messages", ("CreateMessage", channelId), request)!; @@ -110,7 +114,7 @@ namespace Myriad.Rest public Task ExecuteWebhook(ulong webhookId, string webhookToken, ExecuteWebhookRequest request, MultipartFile[]? files = null) => - _client.PostMultipart($"/webhooks/{webhookId}/{webhookToken}", + _client.PostMultipart($"/webhooks/{webhookId}/{webhookToken}?wait=true", ("ExecuteWebhook", webhookId), request, files)!; private static string EncodeEmoji(Emoji emoji) => diff --git a/Myriad/Types/Embed.cs b/Myriad/Types/Embed.cs index 46560cb8..7aa09e17 100644 --- a/Myriad/Types/Embed.cs +++ b/Myriad/Types/Embed.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace Myriad.Types +namespace Myriad.Types { public record Embed { diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index b7914d91..b73a6db3 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -11,6 +11,7 @@ using App.Metrics; using Autofac; using Myriad.Cache; +using Myriad.Extensions; using Myriad.Gateway; using Myriad.Rest; using Myriad.Types; @@ -75,8 +76,19 @@ namespace PluralKit.Bot }, null, timeTillNextWholeMinute, TimeSpan.FromMinutes(1)); } - public GuildMemberPartial? BotMemberIn(ulong guildId) => _guildMembers.GetValueOrDefault(guildId); + public PermissionSet PermissionsIn(ulong channelId) + { + var channel = _cache.GetChannel(channelId); + if (channel.GuildId != null) + { + var member = _guildMembers.GetValueOrDefault(channel.GuildId.Value); + return _cache.PermissionsFor(channelId, _cluster.User?.Id ?? default, member?.Roles); + } + + return PermissionSet.Dm; + } + private async Task OnEventReceived(Shard shard, IGatewayEvent evt) { await _cache.HandleGatewayEvent(evt); diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index a5d8c29a..1402705e 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -37,7 +37,6 @@ namespace PluralKit.Bot private readonly Message _messageNew; private readonly Parameters _parameters; private readonly MessageContext _messageContext; - private readonly GuildMemberPartial? _botMember; private readonly PermissionSet _botPermissions; private readonly PermissionSet _userPermissions; @@ -51,7 +50,7 @@ namespace PluralKit.Bot private Command _currentCommand; public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, - PKSystem senderSystem, MessageContext messageContext, GuildMemberPartial? botMember) + PKSystem senderSystem, MessageContext messageContext, PermissionSet botPermissions) { _rest = provider.Resolve(); _client = provider.Resolve(); @@ -61,7 +60,6 @@ namespace PluralKit.Bot _channel = channel; _senderSystem = senderSystem; _messageContext = messageContext; - _botMember = botMember; _cache = provider.Resolve(); _db = provider.Resolve(); _repo = provider.Resolve(); @@ -71,7 +69,7 @@ namespace PluralKit.Bot _parameters = new Parameters(message.Content.Substring(commandParseOffset)); _newRest = provider.Resolve(); - _botPermissions = _cache.PermissionsFor(message.ChannelId, shard.User!.Id, botMember!); + _botPermissions = botPermissions; _userPermissions = _cache.PermissionsFor(message); } diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index b29d3816..a5868184 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -1,8 +1,6 @@ using System.Linq; using System.Threading.Tasks; -using DSharpPlus; - using Humanizer; using PluralKit.Core; @@ -119,14 +117,6 @@ namespace PluralKit.Bot public static Command[] LogCommands = {LogChannel, LogChannelClear, LogEnable, LogDisable}; public static Command[] BlacklistCommands = {BlacklistAdd, BlacklistRemove, BlacklistShow}; - - private DiscordShardedClient _client; - - public CommandTree(DiscordShardedClient client) - { - - _client = client; - } public Task ExecuteCommand(Context ctx) { diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index 0a72c424..cd5a0f00 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -114,7 +114,7 @@ namespace PluralKit.Bot try { var system = ctx.SystemId != null ? await _db.Execute(c => _repo.GetSystem(c, ctx.SystemId.Value)) : null; - await _tree.ExecuteCommand(new Context(_services, shard, guild, channel, evt, cmdStart, system, ctx, _bot.BotMemberIn(channel.GuildId!.Value))); + await _tree.ExecuteCommand(new Context(_services, shard, guild, channel, evt, cmdStart, system, ctx, _bot.PermissionsIn(channel.Id))); } catch (PKError) { @@ -147,8 +147,7 @@ namespace PluralKit.Bot private async ValueTask TryHandleProxy(Shard shard, MessageCreateEvent evt, Guild guild, Channel channel, MessageContext ctx) { - var botMember = _bot.BotMemberIn(channel.GuildId!.Value); - var botPermissions = PermissionExtensions.PermissionsFor(guild, channel, shard.User!.Id, botMember!.Roles); + var botPermissions = _bot.PermissionsIn(channel.Id); try { diff --git a/PluralKit.Bot/Handlers/MessageDeleted.cs b/PluralKit.Bot/Handlers/MessageDeleted.cs index 3d2c236c..084ee861 100644 --- a/PluralKit.Bot/Handlers/MessageDeleted.cs +++ b/PluralKit.Bot/Handlers/MessageDeleted.cs @@ -34,7 +34,7 @@ namespace PluralKit.Bot { await Task.Delay(MessageDeleteDelay); // TODO - // await _db.Execute(c => _repo.DeleteMessage(c, evt.Message.Id)); + await _db.Execute(c => _repo.DeleteMessage(c, evt.Id)); } // Fork a task to delete the message after a short delay @@ -49,9 +49,10 @@ namespace PluralKit.Bot async Task Inner() { await Task.Delay(MessageDeleteDelay); - // TODO - // _logger.Information("Bulk deleting {Count} messages in channel {Channel}", evt.Messages.Count, evt.Channel.Id); - // await _db.Execute(c => _repo.DeleteMessagesBulk(c, evt.Messages.Select(m => m.Id).ToList())); + + _logger.Information("Bulk deleting {Count} messages in channel {Channel}", + evt.Ids.Length, evt.ChannelId); + await _db.Execute(c => _repo.DeleteMessagesBulk(c, evt.Ids)); } _ = Inner(); diff --git a/PluralKit.Bot/Handlers/ReactionAdded.cs b/PluralKit.Bot/Handlers/ReactionAdded.cs index 6d2c2a15..9c20c3b0 100644 --- a/PluralKit.Bot/Handlers/ReactionAdded.cs +++ b/PluralKit.Bot/Handlers/ReactionAdded.cs @@ -1,11 +1,13 @@ using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; -using DSharpPlus.Exceptions; - +using Myriad.Builders; +using Myriad.Cache; +using Myriad.Extensions; using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Exceptions; +using Myriad.Rest.Types; +using Myriad.Types; using PluralKit.Core; @@ -18,37 +20,42 @@ namespace PluralKit.Bot private readonly IDatabase _db; private readonly ModelRepository _repo; private readonly CommandMessageService _commandMessageService; - private readonly EmbedService _embeds; private readonly ILogger _logger; + private readonly IDiscordCache _cache; + private readonly Bot _bot; + private readonly DiscordApiClient _rest; - public ReactionAdded(EmbedService embeds, ILogger logger, IDatabase db, ModelRepository repo, CommandMessageService commandMessageService) + public ReactionAdded(ILogger logger, IDatabase db, ModelRepository repo, CommandMessageService commandMessageService, IDiscordCache cache, Bot bot, DiscordApiClient rest) { - _embeds = embeds; _db = db; _repo = repo; _commandMessageService = commandMessageService; + _cache = cache; + _bot = bot; + _rest = rest; _logger = logger.ForContext(); } public async Task Handle(Shard shard, MessageReactionAddEvent evt) { - // await TryHandleProxyMessageReactions(shard, evt); + await TryHandleProxyMessageReactions(evt); } - private async ValueTask TryHandleProxyMessageReactions(DiscordClient shard, MessageReactionAddEventArgs evt) + private async ValueTask TryHandleProxyMessageReactions(MessageReactionAddEvent evt) { - // Sometimes we get events from users that aren't in the user cache - // In that case we get a "broken" user object (where eg. calling IsBot throws an exception) // We just ignore all of those for now, should be quite rare... - if (!shard.TryGetCachedUser(evt.User.Id, out _)) return; + if (!_cache.TryGetUser(evt.UserId, out var user)) + return; + + var channel = _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") { await using var conn = await _db.Obtain(); - var commandMsg = await _commandMessageService.GetCommandMessage(conn, evt.Message.Id); + var commandMsg = await _commandMessageService.GetCommandMessage(conn, evt.MessageId); if (commandMsg != null) { await HandleCommandDeleteReaction(evt, commandMsg); @@ -57,10 +64,10 @@ namespace PluralKit.Bot } // Only proxies in guild text channels - if (evt.Channel == null || evt.Channel.Type != ChannelType.Text) return; + if (channel.Type != Channel.ChannelType.GuildText) return; // Ignore reactions from bots (we can't DM them anyway) - if (evt.User.IsBot) return; + if (user.Bot) return; switch (evt.Emoji.Name) { @@ -68,7 +75,7 @@ namespace PluralKit.Bot case "\u274C": // Red X { await using var conn = await _db.Obtain(); - var msg = await _repo.GetMessage(conn, evt.Message.Id); + var msg = await _repo.GetMessage(conn, evt.MessageId); if (msg != null) await HandleProxyDeleteReaction(evt, msg); @@ -78,9 +85,9 @@ namespace PluralKit.Bot case "\u2754": // White question mark { await using var conn = await _db.Obtain(); - var msg = await _repo.GetMessage(conn, evt.Message.Id); + var msg = await _repo.GetMessage(conn, evt.MessageId); if (msg != null) - await HandleQueryReaction(shard, evt, msg); + await HandleQueryReaction(evt, msg); break; } @@ -92,7 +99,7 @@ namespace PluralKit.Bot case "\u2757": // Exclamation mark { await using var conn = await _db.Obtain(); - var msg = await _repo.GetMessage(conn, evt.Message.Id); + var msg = await _repo.GetMessage(conn, evt.MessageId); if (msg != null) await HandlePingReaction(evt, msg); break; @@ -100,37 +107,39 @@ namespace PluralKit.Bot } } - private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEventArgs evt, FullMessage msg) + private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEvent evt, FullMessage msg) { - if (!evt.Channel.BotHasAllPermissions(Permissions.ManageMessages)) return; + if (!_bot.PermissionsIn(evt.ChannelId).HasFlag(PermissionSet.ManageMessages)) + return; // Can only delete your own message - if (msg.Message.Sender != evt.User.Id) return; + if (msg.Message.Sender != evt.UserId) return; try { - await evt.Message.DeleteAsync(); + await _rest.DeleteMessage(evt.ChannelId, evt.MessageId); } catch (NotFoundException) { // Message was deleted by something/someone else before we got to it } - await _db.Execute(c => _repo.DeleteMessage(c, evt.Message.Id)); + await _db.Execute(c => _repo.DeleteMessage(c, evt.MessageId)); } - private async ValueTask HandleCommandDeleteReaction(MessageReactionAddEventArgs evt, CommandMessage msg) + private async ValueTask HandleCommandDeleteReaction(MessageReactionAddEvent evt, CommandMessage msg) { - if (!evt.Channel.BotHasAllPermissions(Permissions.ManageMessages) && evt.Channel.Guild != null) + // TODO: why does the bot need manage messages if it's deleting its own messages?? + if (!_bot.PermissionsIn(evt.ChannelId).HasFlag(PermissionSet.ManageMessages)) return; // Can only delete your own message - if (msg.AuthorId != evt.User.Id) + if (msg.AuthorId != evt.UserId) return; try { - await evt.Message.DeleteAsync(); + await _rest.DeleteMessage(evt.ChannelId, evt.MessageId); } catch (NotFoundException) { @@ -140,44 +149,52 @@ namespace PluralKit.Bot // No need to delete database row here, it'll get deleted by the once-per-minute scheduled task. } - private async ValueTask HandleQueryReaction(DiscordClient shard, MessageReactionAddEventArgs evt, FullMessage msg) + private async ValueTask HandleQueryReaction(MessageReactionAddEvent evt, FullMessage msg) { // Try to DM the user info about the message - var member = await evt.Guild.GetMember(evt.User.Id); + // var member = await evt.Guild.GetMember(evt.User.Id); try { - await member.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, evt.Guild, LookupContext.ByNonOwner)); - await member.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(shard, msg)); + // TODO: how to DM? + // await member.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, evt.Guild, LookupContext.ByNonOwner)); + // await member.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(shard, msg)); } catch (UnauthorizedException) { } // No permissions to DM, can't check for this :( await TryRemoveOriginalReaction(evt); } - private async ValueTask HandlePingReaction(MessageReactionAddEventArgs evt, FullMessage msg) + private async ValueTask HandlePingReaction(MessageReactionAddEvent evt, FullMessage msg) { - if (!evt.Channel.BotHasAllPermissions(Permissions.SendMessages)) return; + if (!_bot.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 guildUser = await evt.Guild.GetMember(evt.User.Id); - var requiredPerms = Permissions.AccessChannels | Permissions.SendMessages; - if (guildUser == null || (guildUser.PermissionsIn(evt.Channel) & requiredPerms) != requiredPerms) return; + var member = await _rest.GetGuildMember(evt.GuildId!.Value, evt.UserId); + var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages; + if (member == null || !_cache.PermissionsFor(evt.ChannelId, member).HasFlag(requiredPerms)) return; if (msg.System.PingsEnabled) { // If the system has pings enabled, go ahead - var embed = new DiscordEmbedBuilder().WithDescription($"[Jump to pinged message]({evt.Message.JumpLink})"); - await evt.Channel.SendMessageFixedAsync($"Psst, **{msg.Member.DisplayName()}** (<@{msg.Message.Sender}>), you have been pinged by <@{evt.User.Id}>.", embed: embed.Build(), - new IMention[] {new UserMention(msg.Message.Sender) }); + var embed = new EmbedBuilder().Description($"[Jump to pinged message]({evt.JumpLink()})"); + await _rest.CreateMessage(evt.ChannelId, new() + { + Content = + $"Psst, **{msg.Member.DisplayName()}** (<@{msg.Message.Sender}>), you have been pinged by <@{evt.UserId}>.", + Embed = embed.Build(), + AllowedMentions = new AllowedMentions {Users = new[] {msg.Message.Sender}} + }); } else { // If not, tell them in DMs (if we can) try { - await guildUser.SendMessageFixedAsync($"{Emojis.Error} {msg.Member.DisplayName()}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:"); - await guildUser.SendMessageFixedAsync($"<@{msg.Message.Sender}>".AsCode()); + // todo: how to dm + // await guildUser.SendMessageFixedAsync($"{Emojis.Error} {msg.Member.DisplayName()}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:"); + // await guildUser.SendMessageFixedAsync($"<@{msg.Message.Sender}>".AsCode()); } catch (UnauthorizedException) { } } @@ -185,21 +202,10 @@ namespace PluralKit.Bot await TryRemoveOriginalReaction(evt); } - private async Task TryRemoveOriginalReaction(MessageReactionAddEventArgs evt) + private async Task TryRemoveOriginalReaction(MessageReactionAddEvent evt) { - try - { - if (evt.Channel.BotHasAllPermissions(Permissions.ManageMessages)) - await evt.Message.DeleteReactionAsync(evt.Emoji, evt.User); - } - catch (UnauthorizedException) - { - var botPerms = evt.Channel.BotPermissions(); - // So, in some cases (see Sentry issue 11K) the above check somehow doesn't work, and - // Discord returns a 403 Unauthorized. TODO: figure out the root cause here instead of a workaround - _logger.Warning("Attempted to remove reaction {Emoji} from user {User} on message {Channel}/{Message}, but got 403. Bot has permissions {Permissions} according to itself.", - evt.Emoji.Id, evt.User.Id, evt.Channel.Id, evt.Message.Id, botPerms); - } + if (_bot.PermissionsIn(evt.ChannelId).HasFlag(PermissionSet.ManageMessages)) + await _rest.DeleteOwnReaction(evt.ChannelId, evt.MessageId, evt.Emoji); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 0631d8b1..da8b7d10 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -8,6 +8,7 @@ using DSharpPlus.Entities; using Humanizer; +using Myriad.Builders; using Myriad.Cache; using Myriad.Rest; using Myriad.Types; @@ -62,55 +63,52 @@ namespace PluralKit.Bot { var memberCount = cctx.MatchPrivateFlag(ctx) ? await _repo.GetSystemMemberCount(conn, system.Id, PrivacyLevel.Public) : await _repo.GetSystemMemberCount(conn, system.Id); - var embed = new Embed - { - Title = system.Name, - Thumbnail = new(system.AvatarUrl), - Footer = new($"System ID: {system.Hid} | Created on {system.Created.FormatZoned(system)}"), - Color = (uint?) DiscordUtils.Gray.Value - }; - var fields = new List(); - + var eb = new EmbedBuilder() + .Title(system.Name) + .Thumbnail(new(system.AvatarUrl)) + .Footer(new($"System ID: {system.Hid} | Created on {system.Created.FormatZoned(system)}")) + .Color((uint) DiscordUtils.Gray.Value); + var latestSwitch = await _repo.GetLatestSwitch(conn, system.Id); if (latestSwitch != null && system.FrontPrivacy.CanAccess(ctx)) { var switchMembers = await _repo.GetSwitchMembers(conn, latestSwitch.Id).ToListAsync(); if (switchMembers.Count > 0) - fields.Add(new("Fronter".ToQuantity(switchMembers.Count, ShowQuantityAs.None), string.Join(", ", switchMembers.Select(m => m.NameFor(ctx))))); + eb.Field(new("Fronter".ToQuantity(switchMembers.Count, ShowQuantityAs.None), string.Join(", ", switchMembers.Select(m => m.NameFor(ctx))))); } if (system.Tag != null) - fields.Add(new("Tag", system.Tag.EscapeMarkdown())); - fields.Add(new("Linked accounts", string.Join("\n", users).Truncate(1000), true)); + eb.Field(new("Tag", system.Tag.EscapeMarkdown())); + eb.Field(new("Linked accounts", string.Join("\n", users).Truncate(1000), true)); if (system.MemberListPrivacy.CanAccess(ctx)) { if (memberCount > 0) - fields.Add(new($"Members ({memberCount})", $"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)", true)); + eb.Field(new($"Members ({memberCount})", $"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)", true)); else - fields.Add(new($"Members ({memberCount})", "Add one with `pk;member new`!", true)); + eb.Field(new($"Members ({memberCount})", "Add one with `pk;member new`!", true)); } if (system.DescriptionFor(ctx) is { } desc) - fields.Add(new("Description", desc.NormalizeLineEndSpacing().Truncate(1024), false)); + eb.Field(new("Description", desc.NormalizeLineEndSpacing().Truncate(1024), false)); - return embed with { Fields = fields.ToArray() }; + return eb.Build(); } - public DiscordEmbed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordUser sender, string content, DiscordChannel channel) { + public Embed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, User sender, string content, Channel channel) { // TODO: pronouns in ?-reacted response using this card var timestamp = DiscordUtils.SnowflakeToInstant(messageId); var name = member.NameFor(LookupContext.ByNonOwner); - return new DiscordEmbedBuilder() - .WithAuthor($"#{channel.Name}: {name}", iconUrl: DiscordUtils.WorkaroundForUrlBug(member.AvatarFor(LookupContext.ByNonOwner))) - .WithThumbnail(member.AvatarFor(LookupContext.ByNonOwner)) - .WithDescription(content?.NormalizeLineEndSpacing()) - .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: {messageId} | Original Message ID: {originalMsgId}") - .WithTimestamp(timestamp.ToDateTimeOffset()) + return new EmbedBuilder() + .Author(new($"#{channel.Name}: {name}", IconUrl: DiscordUtils.WorkaroundForUrlBug(member.AvatarFor(LookupContext.ByNonOwner)))) + .Thumbnail(new(member.AvatarFor(LookupContext.ByNonOwner))) + .Description(content?.NormalizeLineEndSpacing()) + .Footer(new($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: {messageId} | Original Message ID: {originalMsgId}")) + .Timestamp(timestamp.ToDateTimeOffset().ToString("O")) .Build(); } - public async Task CreateMemberEmbed(PKSystem system, PKMember member, DiscordGuild guild, LookupContext ctx) + public async Task CreateMemberEmbed(PKSystem system, PKMember member, DiscordGuild guild, LookupContext ctx) { // string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone)); @@ -141,13 +139,14 @@ namespace PluralKit.Bot { .Where(g => g.Visibility.CanAccess(ctx)) .OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase) .ToListAsync(); - - var eb = new DiscordEmbedBuilder() + + var eb = new EmbedBuilder() // TODO: add URL of website when that's up - .WithAuthor(name, iconUrl: DiscordUtils.WorkaroundForUrlBug(avatar)) + .Author(new(name, IconUrl: DiscordUtils.WorkaroundForUrlBug(avatar))) // .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray) - .WithColor(color) - .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(system)}":"")}"); + .Color((uint?) color.Value) + .Footer(new( + $"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(system)}" : "")}")); var description = ""; if (member.MemberVisibility == PrivacyLevel.Private) description += "*(this member is hidden)*\n"; @@ -156,21 +155,21 @@ namespace PluralKit.Bot { description += $"*(this member has a server-specific avatar set; [click here]({member.AvatarUrl}) to see the global avatar)*\n"; else description += "*(this member has a server-specific avatar set)*\n"; - if (description != "") eb.WithDescription(description); + if (description != "") eb.Description(description); - if (avatar != null) eb.WithThumbnail(avatar); + if (avatar != null) eb.Thumbnail(new(avatar)); - if (!member.DisplayName.EmptyOrNull() && member.NamePrivacy.CanAccess(ctx)) eb.AddField("Display Name", member.DisplayName.Truncate(1024), true); - if (guild != null && guildDisplayName != null) eb.AddField($"Server Nickname (for {guild.Name})", guildDisplayName.Truncate(1024), true); - if (member.BirthdayFor(ctx) != null) eb.AddField("Birthdate", member.BirthdayString, true); - if (member.PronounsFor(ctx) is {} pronouns && !string.IsNullOrWhiteSpace(pronouns)) eb.AddField("Pronouns", pronouns.Truncate(1024), true); - if (member.MessageCountFor(ctx) is {} count && count > 0) eb.AddField("Message Count", member.MessageCount.ToString(), true); - if (member.HasProxyTags) eb.AddField("Proxy Tags", member.ProxyTagsString("\n").Truncate(1024), true); + if (!member.DisplayName.EmptyOrNull() && member.NamePrivacy.CanAccess(ctx)) eb.Field(new("Display Name", member.DisplayName.Truncate(1024), true)); + if (guild != null && guildDisplayName != null) eb.Field(new($"Server Nickname (for {guild.Name})", guildDisplayName.Truncate(1024), true)); + if (member.BirthdayFor(ctx) != null) eb.Field(new("Birthdate", member.BirthdayString, true)); + if (member.PronounsFor(ctx) is {} pronouns && !string.IsNullOrWhiteSpace(pronouns)) eb.Field(new("Pronouns", pronouns.Truncate(1024), true)); + if (member.MessageCountFor(ctx) is {} count && count > 0) eb.Field(new("Message Count", member.MessageCount.ToString(), true)); + if (member.HasProxyTags) eb.Field(new("Proxy Tags", member.ProxyTagsString("\n").Truncate(1024), true)); // --- For when this gets added to the member object itself or however they get added // if (member.LastMessage != null && member.MetadataPrivacy.CanAccess(ctx)) eb.AddField("Last message:" FormatTimestamp(DiscordUtils.SnowflakeToInstant(m.LastMessage.Value))); // if (member.LastSwitchTime != null && m.MetadataPrivacy.CanAccess(ctx)) eb.AddField("Last switched in:", FormatTimestamp(member.LastSwitchTime.Value)); // if (!member.Color.EmptyOrNull() && member.ColorPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true); - if (!member.Color.EmptyOrNull()) eb.AddField("Color", $"#{member.Color}", true); + if (!member.Color.EmptyOrNull()) eb.Field(new("Color", $"#{member.Color}", true)); if (groups.Count > 0) { @@ -178,15 +177,16 @@ namespace PluralKit.Bot { var content = groups.Count > 5 ? string.Join(", ", groups.Select(g => g.DisplayName ?? g.Name)) : string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**")); - eb.AddField($"Groups ({groups.Count})", content.Truncate(1000)); + eb.Field(new($"Groups ({groups.Count})", content.Truncate(1000))); } - if (member.DescriptionFor(ctx) is {} desc) eb.AddField("Description", member.Description.NormalizeLineEndSpacing(), false); + if (member.DescriptionFor(ctx) is {} desc) + eb.Field(new("Description", member.Description.NormalizeLineEndSpacing(), false)); return eb.Build(); } - public async Task CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target) + public async Task CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target) { await using var conn = await _db.Obtain(); @@ -197,43 +197,43 @@ namespace PluralKit.Bot { if (system.Name != null) nameField = $"{nameField} ({system.Name})"; - var eb = new DiscordEmbedBuilder() - .WithAuthor(nameField, iconUrl: DiscordUtils.WorkaroundForUrlBug(target.IconFor(pctx))) - .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); + var eb = new EmbedBuilder() + .Author(new(nameField, IconUrl: DiscordUtils.WorkaroundForUrlBug(target.IconFor(pctx)))) + .Footer(new($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}")); if (target.DisplayName != null) - eb.AddField("Display Name", target.DisplayName); + eb.Field(new("Display Name", target.DisplayName)); if (target.ListPrivacy.CanAccess(pctx)) { if (memberCount == 0 && pctx == LookupContext.ByOwner) // Only suggest the add command if this is actually the owner lol - eb.AddField("Members (0)", $"Add one with `pk;group {target.Reference()} add `!", true); + eb.Field(new("Members (0)", $"Add one with `pk;group {target.Reference()} add `!", true)); else - eb.AddField($"Members ({memberCount})", $"(see `pk;group {target.Reference()} list`)", true); + eb.Field(new($"Members ({memberCount})", $"(see `pk;group {target.Reference()} list`)", true)); } - if (target.DescriptionFor(pctx) is {} desc) - eb.AddField("Description", desc); + if (target.DescriptionFor(pctx) is { } desc) + eb.Field(new("Description", desc)); if (target.IconFor(pctx) is {} icon) - eb.WithThumbnail(icon); + eb.Thumbnail(new(icon)); return eb.Build(); } - public async Task CreateFronterEmbed(PKSwitch sw, DateTimeZone zone, LookupContext ctx) + public async Task CreateFronterEmbed(PKSwitch sw, DateTimeZone zone, LookupContext ctx) { var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id).ToListAsync().AsTask()); var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; - return new DiscordEmbedBuilder() - .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordUtils.Gray) - .AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.NameFor(ctx))) : "*(no fronter)*") - .AddField("Since", $"{sw.Timestamp.FormatZoned(zone)} ({timeSinceSwitch.FormatDuration()} ago)") + return new EmbedBuilder() + .Color((uint?) (members.FirstOrDefault()?.Color?.ToDiscordColor()?.Value ?? DiscordUtils.Gray.Value)) + .Field(new($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.NameFor(ctx))) : "*(no fronter)*")) + .Field(new("Since", $"{sw.Timestamp.FormatZoned(zone)} ({timeSinceSwitch.FormatDuration()} ago)")) .Build(); } - public async Task CreateMessageInfoEmbed(DiscordClient client, FullMessage msg) + public async Task CreateMessageInfoEmbed(DiscordClient client, FullMessage msg) { var ctx = LookupContext.ByNonOwner; var channel = await _client.GetChannel(msg.Message.Channel); @@ -257,32 +257,32 @@ namespace PluralKit.Bot { else userStr = $"*(deleted user {msg.Message.Sender})*"; // Put it all together - var eb = new DiscordEmbedBuilder() - .WithAuthor(msg.Member.NameFor(ctx), iconUrl: DiscordUtils.WorkaroundForUrlBug(msg.Member.AvatarFor(ctx))) - .WithDescription(serverMsg?.Content?.NormalizeLineEndSpacing() ?? "*(message contents deleted or inaccessible)*") - .WithImageUrl(serverMsg?.Attachments?.FirstOrDefault()?.Url) - .AddField("System", - msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true) - .AddField("Member", $"{msg.Member.NameFor(ctx)} (`{msg.Member.Hid}`)", true) - .AddField("Sent by", userStr, inline: true) - .WithTimestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset()); + var eb = new EmbedBuilder() + .Author(new(msg.Member.NameFor(ctx), IconUrl: DiscordUtils.WorkaroundForUrlBug(msg.Member.AvatarFor(ctx)))) + .Description(serverMsg?.Content?.NormalizeLineEndSpacing() ?? "*(message contents deleted or inaccessible)*") + .Image(new(serverMsg?.Attachments?.FirstOrDefault()?.Url)) + .Field(new("System", + msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true)) + .Field(new("Member", $"{msg.Member.NameFor(ctx)} (`{msg.Member.Hid}`)", true)) + .Field(new("Sent by", userStr, true)) + .Timestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset().ToString("O")); var roles = memberInfo?.Roles?.ToList(); if (roles != null && roles.Count > 0) { var rolesString = string.Join(", ", roles.Select(role => role.Name)); - eb.AddField($"Account roles ({roles.Count})", rolesString.Truncate(1024)); + eb.Field(new($"Account roles ({roles.Count})", rolesString.Truncate(1024))); } return eb.Build(); } - public Task CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz, LookupContext ctx) + public Task CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz, LookupContext ctx) { var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart; - var eb = new DiscordEmbedBuilder() - .WithColor(DiscordUtils.Gray) - .WithFooter($"Since {breakdown.RangeStart.FormatZoned(tz)} ({actualPeriod.FormatDuration()} ago)"); + var eb = new EmbedBuilder() + .Color((uint?) DiscordUtils.Gray.Value) + .Footer(new($"Since {breakdown.RangeStart.FormatZoned(tz)} ({actualPeriod.FormatDuration()} ago)")); var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others" @@ -296,15 +296,15 @@ namespace PluralKit.Bot { foreach (var pair in membersOrdered) { var frac = pair.Value / actualPeriod; - eb.AddField(pair.Key?.NameFor(ctx) ?? "*(no fronter)*", $"{frac*100:F0}% ({pair.Value.FormatDuration()})"); + eb.Field(new(pair.Key?.NameFor(ctx) ?? "*(no fronter)*", $"{frac*100:F0}% ({pair.Value.FormatDuration()})")); } if (membersOrdered.Count > maxEntriesToDisplay) { - eb.AddField("(others)", + eb.Field(new("(others)", membersOrdered.Skip(maxEntriesToDisplay) .Aggregate(Duration.Zero, (prod, next) => prod + next.Value) - .FormatDuration(), true); + .FormatDuration(), true)); } return Task.FromResult(eb.Build()); diff --git a/PluralKit.Bot/Services/ErrorMessageService.cs b/PluralKit.Bot/Services/ErrorMessageService.cs index 1a22e04b..bd4a6581 100644 --- a/PluralKit.Bot/Services/ErrorMessageService.cs +++ b/PluralKit.Bot/Services/ErrorMessageService.cs @@ -4,7 +4,8 @@ using System.Threading.Tasks; using App.Metrics; -using DSharpPlus.Entities; +using Myriad.Builders; +using Myriad.Rest; using NodaTime; @@ -19,54 +20,61 @@ namespace PluralKit.Bot private readonly IMetrics _metrics; private readonly ILogger _logger; + private readonly DiscordApiClient _rest; - public ErrorMessageService(IMetrics metrics, ILogger logger) + public ErrorMessageService(IMetrics metrics, ILogger logger, DiscordApiClient rest) { _metrics = metrics; _logger = logger; + _rest = rest; } - public async Task SendErrorMessage(DiscordChannel channel, string errorId) + public async Task SendErrorMessage(ulong channelId, string errorId) { var now = SystemClock.Instance.GetCurrentInstant(); - if (!ShouldSendErrorMessage(channel, now)) + if (!ShouldSendErrorMessage(channelId, now)) { - _logger.Warning("Rate limited sending error message to {ChannelId} with error code {ErrorId}", channel.Id, errorId); + _logger.Warning("Rate limited sending error message to {ChannelId} with error code {ErrorId}", channelId, errorId); _metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "throttled"); return; } - var embed = new DiscordEmbedBuilder() - .WithColor(new DiscordColor(0xE74C3C)) - .WithTitle("Internal error occurred") - .WithDescription("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.") - .WithFooter(errorId) - .WithTimestamp(now.ToDateTimeOffset()); + var 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(errorId)) + .Timestamp(now.ToDateTimeOffset().ToString("O")); try { - await channel.SendMessageAsync($"> **Error code:** `{errorId}`", embed: embed.Build()); - _logger.Information("Sent error message to {ChannelId} with error code {ErrorId}", channel.Id, errorId); + await _rest.CreateMessage(channelId, new() + { + Content = $"> **Error code:** `{errorId}`", + Embed = embed.Build() + }); + + _logger.Information("Sent error message to {ChannelId} with error code {ErrorId}", channelId, errorId); _metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "sent"); } catch (Exception e) { - _logger.Error(e, "Error sending error message to {ChannelId}", channel.Id); + _logger.Error(e, "Error sending error message to {ChannelId}", channelId); _metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "failed"); throw; } } - private bool ShouldSendErrorMessage(DiscordChannel channel, Instant now) + private bool ShouldSendErrorMessage(ulong channelId, Instant now) { - if (_lastErrorInChannel.TryGetValue(channel.Id, out var lastErrorTime)) + if (_lastErrorInChannel.TryGetValue(channelId, out var lastErrorTime)) { var interval = now - lastErrorTime; if (interval < MinErrorInterval) return false; } - _lastErrorInChannel[channel.Id] = now; + _lastErrorInChannel[channelId] = now; return true; } } diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index c4bad54a..d08e42a7 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Dapper; using Myriad.Cache; +using Myriad.Extensions; using Myriad.Rest; using Myriad.Types; @@ -18,14 +19,16 @@ namespace PluralKit.Bot { private readonly ILogger _logger; private readonly IDiscordCache _cache; private readonly DiscordApiClient _rest; + private readonly Bot _bot; - public LogChannelService(EmbedService embed, ILogger logger, IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest) + public LogChannelService(EmbedService embed, ILogger logger, IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest, Bot bot) { _embed = embed; _db = db; _repo = repo; _cache = cache; _rest = rest; + _bot = bot; _logger = logger.ForContext(); } @@ -36,25 +39,28 @@ namespace PluralKit.Bot { // Find log channel and check if valid var logChannel = await FindLogChannel(trigger.GuildId!.Value, ctx.LogChannel.Value); if (logChannel == null || logChannel.Type != Channel.ChannelType.GuildText) return; + + var triggerChannel = _cache.GetChannel(trigger.ChannelId); // Check bot permissions - // if (!logChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks)) - // { - // _logger.Information( - // "Does not have permission to proxy log, ignoring (channel: {ChannelId}, guild: {GuildId}, bot permissions: {BotPermissions})", - // ctx.LogChannel.Value, trigger.GuildId!.Value, trigger.Channel.BotPermissions()); - // return; - // } - // + var perms = _bot.PermissionsIn(logChannel.Id); + if (!perms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks)) + { + _logger.Information( + "Does not have permission to proxy log, ignoring (channel: {ChannelId}, guild: {GuildId}, bot permissions: {BotPermissions})", + ctx.LogChannel.Value, trigger.GuildId!.Value, perms); + return; + } + // Send embed! // TODO: fix? - // await using var conn = await _db.Obtain(); - // var embed = _embed.CreateLoggedMessageEmbed(await _repo.GetSystem(conn, ctx.SystemId.Value), - // await _repo.GetMember(conn, proxy.Member.Id), hookMessage, trigger.Id, trigger.Author, proxy.Content, - // trigger.Channel); - // var url = $"https://discord.com/channels/{trigger.Channel.GuildId}/{trigger.ChannelId}/{hookMessage}"; - // await logChannel.SendMessageFixedAsync(content: url, embed: embed); + await using var conn = await _db.Obtain(); + var embed = _embed.CreateLoggedMessageEmbed(await _repo.GetSystem(conn, ctx.SystemId.Value), + await _repo.GetMember(conn, proxy.Member.Id), hookMessage, trigger.Id, trigger.Author, proxy.Content, + triggerChannel); + var url = $"https://discord.com/channels/{trigger.GuildId}/{trigger.ChannelId}/{hookMessage}"; + await _rest.CreateMessage(logChannel.Id, new() {Content = url, Embed = embed}); } private async Task FindLogChannel(ulong guildId, ulong channelId) From 47b16dc51baaa9fc716b2e962e5a046a898bf563 Mon Sep 17 00:00:00 2001 From: Ske Date: Thu, 24 Dec 2020 14:52:44 +0100 Subject: [PATCH 04/26] Port more things! --- Myriad/Cache/DiscordCacheExtensions.cs | 22 ++- Myriad/Extensions/UserExtensions.cs | 4 +- Myriad/Gateway/Cluster.cs | 1 + Myriad/Gateway/Shard.cs | 2 + Myriad/Rest/DiscordApiClient.cs | 7 +- Myriad/Rest/Ratelimit/Bucket.cs | 6 +- .../Rest/Types/Requests/MessageEditRequest.cs | 18 ++- Myriad/Rest/Types/Requests/MessageRequest.cs | 2 +- Myriad/Serialization/OptionalConverter.cs | 43 ++++++ Myriad/Utils/Optional.cs | 32 +++++ PluralKit.Bot/CommandSystem/Context.cs | 21 +-- .../ContextEntityArgumentsExt.cs | 9 +- PluralKit.Bot/Commands/Autoproxy.cs | 31 +++-- .../Commands/Avatars/ContextAvatarExt.cs | 8 +- PluralKit.Bot/Commands/Groups.cs | 8 +- .../Commands/Lists/ContextListExt.cs | 14 +- PluralKit.Bot/Commands/MemberAvatar.cs | 4 +- PluralKit.Bot/Commands/Misc.cs | 124 ++++++++++------- PluralKit.Bot/Commands/ServerConfig.cs | 4 +- PluralKit.Bot/Commands/SystemEdit.cs | 4 +- PluralKit.Bot/Commands/SystemFront.cs | 4 +- PluralKit.Bot/Commands/SystemLink.cs | 7 +- PluralKit.Bot/Modules.cs | 2 + PluralKit.Bot/Utils/ContextUtils.cs | 127 +++++++++--------- PluralKit.Bot/Utils/DiscordUtils.cs | 12 +- PluralKit.Core/Utils/HandlerQueue.cs | 2 +- 26 files changed, 332 insertions(+), 186 deletions(-) create mode 100644 Myriad/Serialization/OptionalConverter.cs create mode 100644 Myriad/Utils/Optional.cs diff --git a/Myriad/Cache/DiscordCacheExtensions.cs b/Myriad/Cache/DiscordCacheExtensions.cs index ff9a251f..b4165987 100644 --- a/Myriad/Cache/DiscordCacheExtensions.cs +++ b/Myriad/Cache/DiscordCacheExtensions.cs @@ -1,6 +1,8 @@ using System.Threading.Tasks; using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Types; namespace Myriad.Cache { @@ -29,7 +31,7 @@ namespace Myriad.Cache case GuildRoleDeleteEvent grd: return cache.RemoveRole(grd.GuildId, grd.RoleId); case MessageCreateEvent mc: - return cache.SaveUser(mc.Author); + return cache.SaveMessageCreate(mc); } return default; @@ -46,5 +48,23 @@ namespace Myriad.Cache foreach (var member in guildCreate.Members) await cache.SaveUser(member.User); } + + private static async ValueTask SaveMessageCreate(this IDiscordCache cache, MessageCreateEvent evt) + { + await cache.SaveUser(evt.Author); + foreach (var mention in evt.Mentions) + await cache.SaveUser(mention); + } + + public static async ValueTask GetOrFetchUser(this IDiscordCache cache, DiscordApiClient rest, ulong userId) + { + if (cache.TryGetUser(userId, out var cacheUser)) + return cacheUser; + + var restUser = await rest.GetUser(userId); + if (restUser != null) + await cache.SaveUser(restUser); + return restUser; + } } } \ No newline at end of file diff --git a/Myriad/Extensions/UserExtensions.cs b/Myriad/Extensions/UserExtensions.cs index e4b1e5ef..81d16706 100644 --- a/Myriad/Extensions/UserExtensions.cs +++ b/Myriad/Extensions/UserExtensions.cs @@ -6,7 +6,7 @@ namespace Myriad.Extensions { public static string Mention(this User user) => $"<@{user.Id}>"; - public static string AvatarUrl(this User user) => - $"https://cdn.discordapp.com/avatars/{user.Id}/{user.Avatar}.png"; + 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 304cfb8a..63e8a2cc 100644 --- a/Myriad/Gateway/Cluster.cs +++ b/Myriad/Gateway/Cluster.cs @@ -27,6 +27,7 @@ namespace Myriad.Gateway public IReadOnlyDictionary Shards => _shards; public ClusterSessionState SessionState => GetClusterState(); public User? User => _shards.Values.Select(s => s.User).FirstOrDefault(s => s != null); + public ApplicationPartial? Application => _shards.Values.Select(s => s.Application).FirstOrDefault(s => s != null); private ClusterSessionState GetClusterState() { diff --git a/Myriad/Gateway/Shard.cs b/Myriad/Gateway/Shard.cs index a4b65592..cb00fb81 100644 --- a/Myriad/Gateway/Shard.cs +++ b/Myriad/Gateway/Shard.cs @@ -32,6 +32,7 @@ namespace Myriad.Gateway public ShardState State { get; private set; } public TimeSpan? Latency { get; private set; } public User? User { get; private set; } + public ApplicationPartial? Application { get; private set; } public Func? OnEventReceived { get; set; } @@ -258,6 +259,7 @@ namespace Myriad.Gateway ShardInfo = ready.Shard; SessionInfo = SessionInfo with { Session = ready.SessionId }; User = ready.User; + Application = ready.Application; State = ShardState.Open; return Task.CompletedTask; diff --git a/Myriad/Rest/DiscordApiClient.cs b/Myriad/Rest/DiscordApiClient.cs index 71813481..953ce2d0 100644 --- a/Myriad/Rest/DiscordApiClient.cs +++ b/Myriad/Rest/DiscordApiClient.cs @@ -33,8 +33,11 @@ namespace Myriad.Rest 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 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)); diff --git a/Myriad/Rest/Ratelimit/Bucket.cs b/Myriad/Rest/Ratelimit/Bucket.cs index 31e7ea24..6210ce89 100644 --- a/Myriad/Rest/Ratelimit/Bucket.cs +++ b/Myriad/Rest/Ratelimit/Bucket.cs @@ -77,8 +77,8 @@ namespace Myriad.Rest.Ratelimit var headerNextReset = DateTimeOffset.UtcNow + headers.ResetAfter.Value; // todo: server time if (headerNextReset > _nextReset) { - _logger.Debug("{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server", - Key, Major, _nextReset); + _logger.Debug("{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server (after: {NextResetAfter})", + Key, Major, headerNextReset, headers.ResetAfter.Value); _nextReset = headerNextReset; _resetTimeValid = true; @@ -101,7 +101,7 @@ namespace Myriad.Rest.Ratelimit _semaphore.Wait(); // If we're past the reset time *and* we haven't reset already, do that - var timeSinceReset = _nextReset - now; + var timeSinceReset = now - _nextReset; var shouldReset = _resetTimeValid && timeSinceReset > TimeSpan.Zero; if (shouldReset) { diff --git a/Myriad/Rest/Types/Requests/MessageEditRequest.cs b/Myriad/Rest/Types/Requests/MessageEditRequest.cs index 1fe03193..bf217c83 100644 --- a/Myriad/Rest/Types/Requests/MessageEditRequest.cs +++ b/Myriad/Rest/Types/Requests/MessageEditRequest.cs @@ -1,10 +1,22 @@ -using Myriad.Types; +using System.Text.Json.Serialization; + +using Myriad.Types; +using Myriad.Utils; namespace Myriad.Rest.Types.Requests { public record MessageEditRequest { - public string? Content { get; set; } - public Embed? Embed { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional Content { 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 AllowedMentions { 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 72f018e5..992eb08e 100644 --- a/Myriad/Rest/Types/Requests/MessageRequest.cs +++ b/Myriad/Rest/Types/Requests/MessageRequest.cs @@ -7,7 +7,7 @@ namespace Myriad.Rest.Types.Requests public string? Content { get; set; } public object? Nonce { get; set; } public bool Tts { get; set; } - public AllowedMentions AllowedMentions { get; set; } + public AllowedMentions? AllowedMentions { get; set; } public Embed? Embed { get; set; } } } \ No newline at end of file diff --git a/Myriad/Serialization/OptionalConverter.cs b/Myriad/Serialization/OptionalConverter.cs new file mode 100644 index 00000000..c45d1caa --- /dev/null +++ b/Myriad/Serialization/OptionalConverter.cs @@ -0,0 +1,43 @@ +using System; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Myriad.Utils; + +namespace Myriad.Serialization +{ + public class OptionalConverter: JsonConverter + { + public override IOptional? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var innerType = typeToConvert.GetGenericArguments()[0]; + var inner = JsonSerializer.Deserialize(ref reader, innerType, options); + + // TODO: rewrite to JsonConverterFactory to cut down on reflection + return (IOptional?) Activator.CreateInstance( + typeof(Optional<>).MakeGenericType(innerType), + BindingFlags.Instance | BindingFlags.Public, + null, + new[] {inner}, + null); + } + + public override void Write(Utf8JsonWriter writer, IOptional value, JsonSerializerOptions options) + { + var innerType = value.GetType().GetGenericArguments()[0]; + JsonSerializer.Serialize(writer, value.GetValue(), innerType, options); + } + + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + if (typeToConvert.GetGenericTypeDefinition() != typeof(Optional<>)) + return false; + + return true; + } + } +} \ No newline at end of file diff --git a/Myriad/Utils/Optional.cs b/Myriad/Utils/Optional.cs new file mode 100644 index 00000000..7b1e4139 --- /dev/null +++ b/Myriad/Utils/Optional.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +using Myriad.Serialization; + +namespace Myriad.Utils +{ + public interface IOptional + { + bool HasValue { get; } + object? GetValue(); + } + + [JsonConverter(typeof(OptionalConverter))] + public readonly struct Optional: IOptional + { + public Optional(T value) + { + HasValue = true; + Value = value; + } + + public bool HasValue { get; } + public object? GetValue() => Value; + + public T Value { get; } + + public static implicit operator Optional(T value) => new(value); + + public static Optional Some(T value) => new(value); + public static Optional None() => default; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index 1402705e..037d8726 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -8,11 +8,11 @@ using Autofac; using DSharpPlus; using DSharpPlus.Entities; -using DSharpPlus.Net; using Myriad.Cache; using Myriad.Extensions; using Myriad.Gateway; +using Myriad.Rest.Types; using Myriad.Rest.Types.Requests; using Myriad.Types; @@ -34,7 +34,7 @@ namespace PluralKit.Bot private readonly Guild? _guild; private readonly Channel _channel; private readonly DiscordMessage _message = null; - private readonly Message _messageNew; + private readonly MessageCreateEvent _messageNew; private readonly Parameters _parameters; private readonly MessageContext _messageContext; private readonly PermissionSet _botPermissions; @@ -79,6 +79,7 @@ namespace PluralKit.Bot public DiscordChannel Channel => _message.Channel; public Channel ChannelNew => _channel; public User AuthorNew => _messageNew.Author; + public GuildMemberPartial MemberNew => _messageNew.Member; public DiscordMessage Message => _message; public Message MessageNew => _messageNew; public DiscordGuild Guild => _message.Channel.Guild; @@ -91,6 +92,7 @@ namespace PluralKit.Bot public PermissionSet UserPermissions => _userPermissions; public DiscordRestClient Rest => _rest; + public DiscordApiClient RestNew => _newRest; public PKSystem System => _senderSystem; @@ -102,16 +104,16 @@ namespace PluralKit.Bot public Task Reply(string text, DiscordEmbed embed, IEnumerable? mentions = null) { - return Reply(text, (DiscordEmbed) null, mentions); + throw new NotImplementedException(); } public Task Reply(DiscordEmbed embed, IEnumerable? mentions = null) { - return Reply(null, (DiscordEmbed) null, mentions); + throw new NotImplementedException(); } - public async Task Reply(string text = null, Embed embed = null, IEnumerable? mentions = null) + public async Task Reply(string text = null, Embed embed = null, AllowedMentions? mentions = null) { if (!BotPermissions.HasFlag(PermissionSet.SendMessages)) // Will be "swallowed" during the error handler anyway, this message is never shown. @@ -123,10 +125,10 @@ namespace PluralKit.Bot var msg = await _newRest.CreateMessage(_channel.Id, new MessageRequest { Content = text, - Embed = embed + Embed = embed, + AllowedMentions = mentions }); - // TODO: embeds/mentions - // var msg = await Channel.SendMessageFixedAsync(text, embed: embed, mentions: mentions); + // TODO: mentions should default to empty and not null? if (embed != null) { @@ -135,8 +137,7 @@ namespace PluralKit.Bot await _commandMessageService.RegisterMessage(msg.Id, AuthorNew.Id); } - // return msg; - return null; + return msg; } public async Task Execute(Command commandDef, Func handler) diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 97e2efa4..cb915c99 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -1,8 +1,6 @@ using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.Entities; - +using Myriad.Cache; using Myriad.Types; using PluralKit.Bot.Utils; @@ -12,11 +10,12 @@ namespace PluralKit.Bot { 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.Shard.GetUser(id); + return await ctx.Cache.GetOrFetchUser(ctx.RestNew, id); + return null; } diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index 94e94dd2..b3efde4e 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -1,10 +1,11 @@ using System; using System.Threading.Tasks; -using DSharpPlus.Entities; - using Humanizer; +using Myriad.Builders; +using Myriad.Types; + using NodaTime; using PluralKit.Core; @@ -84,10 +85,11 @@ namespace PluralKit.Bot await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server."); } - private async Task CreateAutoproxyStatusEmbed(Context ctx) + 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 DiscordEmbedBuilder().WithTitle($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})"); + var eb = new EmbedBuilder() + .Title($"Current autoproxy status (for {ctx.GuildNew.Name.EscapeMarkdown()})"); var fronters = ctx.MessageContext.LastSwitchMembers; var relevantMember = ctx.MessageContext.AutoproxyMode switch @@ -98,35 +100,36 @@ namespace PluralKit.Bot }; switch (ctx.MessageContext.AutoproxyMode) { - case AutoproxyMode.Off: eb.WithDescription($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}"); + case AutoproxyMode.Off: + eb.Description($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}"); break; case AutoproxyMode.Front: { if (fronters.Length == 0) - eb.WithDescription("Autoproxy is currently set to **front mode** in this server, but there are currently no fronters registered. Use the `pk;switch` command to log a switch."); + eb.Description("Autoproxy is currently set to **front mode** in this server, but there are currently no fronters registered. Use the `pk;switch` command to log a switch."); else { if (relevantMember == null) throw new ArgumentException("Attempted to print member autoproxy status, but the linked member ID wasn't found in the database. Should be handled appropriately."); - eb.WithDescription($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`."); + eb.Description($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`."); } break; } // AutoproxyMember is never null if Mode is Member, this is just to make the compiler shut up case AutoproxyMode.Member when relevantMember != null: { - eb.WithDescription($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`."); + eb.Description($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`."); break; } case AutoproxyMode.Latch: - eb.WithDescription("Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. To disable, type `pk;autoproxy off`."); + 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.AddField("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`."); + 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(); } @@ -178,7 +181,7 @@ namespace PluralKit.Bot else { var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled"; - await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>.", mentions: new IMention[]{}); + await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>."); } } @@ -187,18 +190,18 @@ namespace PluralKit.Bot var statusString = allow ? "enabled" : "disabled"; if (ctx.MessageContext.AllowAutoproxy == allow) { - await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.", mentions: new IMention[]{}); + await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>."); return; } var patch = new AccountPatch { AllowAutoproxy = allow }; await _db.Execute(conn => _repo.UpdateAccount(conn, ctx.Author.Id, patch)); - await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.", mentions: new IMention[]{}); + await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); } private Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) { var patch = new SystemGuildPatch {AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember}; - return _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.Guild.Id, patch)); + return _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.GuildNew.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 045f52e5..43207639 100644 --- a/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs +++ b/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs @@ -4,8 +4,8 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.Entities; +using Myriad.Extensions; +using Myriad.Types; namespace PluralKit.Bot { @@ -22,7 +22,7 @@ namespace PluralKit.Bot // If we have a user @mention/ID, use their avatar if (await ctx.MatchUser() is { } user) { - var url = user.GetAvatarUrl(ImageFormat.Png, 256); + var url = user.AvatarUrl("png", 256); return new ParsedImage {Url = url, Source = AvatarSource.User, SourceUser = user}; } @@ -64,7 +64,7 @@ namespace PluralKit.Bot { public string Url; public AvatarSource Source; - public DiscordUser? SourceUser; + public User? SourceUser; } public enum AvatarSource diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index f3f63fa5..36ee74fb 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -10,6 +10,8 @@ using DSharpPlus.Entities; using Humanizer; +using Myriad.Builders; + using PluralKit.Core; namespace PluralKit.Bot @@ -194,7 +196,7 @@ namespace PluralKit.Bot // The attachment's already right there, no need to preview it. var hasEmbed = img.Source != AvatarSource.Attachment; await (hasEmbed - ? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(img.Url).Build()) + ? ctx.Reply(msg, embed: new EmbedBuilder().Image(new(img.Url)).Build()) : ctx.Reply(msg)); } @@ -265,7 +267,7 @@ namespace PluralKit.Bot var title = system.Name != null ? $"Groups of {system.Name} (`{system.Hid}`)" : $"Groups of `{system.Hid}`"; await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, 25, title, Renderer); - Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) + Task Renderer(EmbedBuilder eb, IEnumerable page) { eb.WithSimpleLineContent(page.Select(g => { @@ -274,7 +276,7 @@ namespace PluralKit.Bot else return $"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({"member".ToQuantity(g.MemberCount)})"; })); - eb.WithFooter($"{groups.Count} total."); + eb.Footer(new($"{groups.Count} total.")); return Task.CompletedTask; } } diff --git a/PluralKit.Bot/Commands/Lists/ContextListExt.cs b/PluralKit.Bot/Commands/Lists/ContextListExt.cs index 5c9da71c..acca3b5f 100644 --- a/PluralKit.Bot/Commands/Lists/ContextListExt.cs +++ b/PluralKit.Bot/Commands/Lists/ContextListExt.cs @@ -3,10 +3,10 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using DSharpPlus.Entities; - using Humanizer; +using Myriad.Builders; + using NodaTime; using PluralKit.Core; @@ -90,10 +90,10 @@ namespace PluralKit.Bot await ctx.Paginate(members.ToAsyncEnumerable(), members.Count, itemsPerPage, embedTitle, Renderer); // Base renderer, dispatches based on type - Task Renderer(DiscordEmbedBuilder eb, IEnumerable page) + Task Renderer(EmbedBuilder eb, IEnumerable page) { // Add a global footer with the filter/sort string + result count - eb.WithFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}."); + eb.Footer(new($"{opts.CreateFilterString()}. {"result".ToQuantity(members.Count)}.")); // Then call the specific renderers if (opts.Type == ListType.Short) @@ -104,7 +104,7 @@ namespace PluralKit.Bot return Task.CompletedTask; } - void ShortRenderer(DiscordEmbedBuilder eb, IEnumerable page) + 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" :) @@ -122,7 +122,7 @@ namespace PluralKit.Bot })); } - void LongRenderer(DiscordEmbedBuilder eb, IEnumerable page) + void LongRenderer(EmbedBuilder eb, IEnumerable page) { var zone = ctx.System?.Zone ?? DateTimeZone.Utc; foreach (var m in page) @@ -162,7 +162,7 @@ namespace PluralKit.Bot if (m.MemberVisibility == PrivacyLevel.Private) profile.Append("\n*(this member is hidden)*"); - eb.AddField(m.NameFor(ctx), profile.ToString().Truncate(1024)); + eb.Field(new(m.NameFor(ctx), profile.ToString().Truncate(1024))); } } } diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 06d5d9c7..65ad0b56 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using DSharpPlus.Entities; +using Myriad.Builders; + using PluralKit.Core; namespace PluralKit.Bot @@ -135,7 +137,7 @@ namespace PluralKit.Bot // The attachment's already right there, no need to preview it. var hasEmbed = avatar.Source != AvatarSource.Attachment; return hasEmbed - ? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(avatar.Url).Build()) + ? ctx.Reply(msg, embed: new EmbedBuilder().Image(new(avatar.Url)).Build()) : ctx.Reply(msg); } diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index fb814f84..04b465ee 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -13,7 +13,16 @@ using Humanizer; using NodaTime; using PluralKit.Core; -using DSharpPlus.Entities; + +using Myriad.Builders; +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Gateway; +using Myriad.Rest; +using Myriad.Rest.Types.Requests; +using Myriad.Types; + +using Permissions = DSharpPlus.Permissions; namespace PluralKit.Bot { public class Misc @@ -25,8 +34,12 @@ namespace PluralKit.Bot { 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; - public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards, EmbedService embeds, ModelRepository repo, IDatabase db) + public Misc(BotConfig botConfig, IMetrics metrics, CpuStatService cpu, ShardInfoService shards, EmbedService embeds, ModelRepository repo, IDatabase db, IDiscordCache cache, DiscordApiClient rest, Bot bot, Cluster cluster) { _botConfig = botConfig; _metrics = metrics; @@ -35,20 +48,26 @@ namespace PluralKit.Bot { _embeds = embeds; _repo = repo; _db = db; + _cache = cache; + _rest = rest; + _bot = bot; + _cluster = cluster; } public async Task Invite(Context ctx) { - var clientId = _botConfig.ClientId ?? ctx.Client.CurrentApplication.Id; - var permissions = new Permissions() - .Grant(Permissions.AddReactions) - .Grant(Permissions.AttachFiles) - .Grant(Permissions.EmbedLinks) - .Grant(Permissions.ManageMessages) - .Grant(Permissions.ManageWebhooks) - .Grant(Permissions.ReadMessageHistory) - .Grant(Permissions.SendMessages); - var invite = $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot%20applications.commands&permissions={(long)permissions}"; + var clientId = _botConfig.ClientId ?? _cluster.Application?.Id; + + 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}>"); } @@ -69,6 +88,7 @@ namespace PluralKit.Bot { var totalSwitches = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.SwitchCount.Name)?.Value ?? 0; var totalMessages = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.MessageCount.Name)?.Value ?? 0; + // TODO: shard stuff var shardId = ctx.Shard.ShardId; var shardTotal = ctx.Client.ShardClients.Count; var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count(); @@ -79,30 +99,31 @@ namespace PluralKit.Bot { var shardUptime = SystemClock.Instance.GetCurrentInstant() - shardInfo.LastConnectionTime; - var embed = new DiscordEmbedBuilder(); - if (messagesReceived != null) embed.AddField("Messages processed",$"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", true); - if (messagesProxied != null) embed.AddField("Messages proxied", $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", true); - if (commandsRun != null) embed.AddField("Commands executed", $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", true); + 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 - .AddField("Current shard", $"Shard #{shardId} (of {shardTotal} total, {shardUpTotal} are up)", true) - .AddField("Shard uptime", $"{shardUptime.FormatDuration()} ({shardInfo.DisconnectionCount} disconnections)", true) - .AddField("CPU usage", $"{_cpu.LastCpuMeasure:P1}", true) - .AddField("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true) - .AddField("Latency", $"API: {apiLatency.TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency.Milliseconds} ms", true) - .AddField("Total numbers", $"{totalSystems:N0} systems, {totalMembers:N0} members, {totalGroups:N0} groups, {totalSwitches:N0} switches, {totalMessages:N0} messages"); - await msg.ModifyAsync("", embed.Build()); + .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", $"{totalSystems:N0} systems, {totalMembers:N0} members, {totalGroups:N0} groups, {totalSwitches:N0} switches, {totalMessages:N0} messages")); + await ctx.RestNew.EditMessage(msg.ChannelId, msg.Id, + new MessageEditRequest {Content = "", Embed = embed.Build()}); } public async Task PermCheckGuild(Context ctx) { - DiscordGuild guild; - DiscordMember senderGuildUser = null; + Guild guild; + GuildMemberPartial senderGuildUser = null; - if (ctx.Guild != null && !ctx.HasNext()) + if (ctx.GuildNew != null && !ctx.HasNext()) { - guild = ctx.Guild; - senderGuildUser = (DiscordMember)ctx.Author; + guild = ctx.GuildNew; + senderGuildUser = ctx.MemberNew; } else { @@ -110,31 +131,33 @@ namespace PluralKit.Bot { if (!ulong.TryParse(guildIdStr, out var guildId)) throw new PKSyntaxError($"Could not parse {guildIdStr.AsCode()} as an ID."); - guild = ctx.Client.GetGuild(guildId); - if (guild != null) senderGuildUser = await guild.GetMember(ctx.Author.Id); - if (guild == null || senderGuildUser == null) throw Errors.GuildNotFound(guildId); + guild = await _rest.GetGuild(guildId); + if (guild != null) + senderGuildUser = await _rest.GetGuildMember(guildId, ctx.AuthorNew.Id); + if (guild == null || senderGuildUser == null) + throw Errors.GuildNotFound(guildId); } var requiredPermissions = new [] { - Permissions.AccessChannels, - Permissions.SendMessages, - Permissions.AddReactions, - Permissions.AttachFiles, - Permissions.EmbedLinks, - Permissions.ManageMessages, - Permissions.ManageWebhooks + PermissionSet.ViewChannel, + PermissionSet.SendMessages, + PermissionSet.AddReactions, + PermissionSet.AttachFiles, + PermissionSet.EmbedLinks, + PermissionSet.ManageMessages, + PermissionSet.ManageWebhooks }; // Loop through every channel and group them by sets of permissions missing - var permissionsMissing = new Dictionary>(); + var permissionsMissing = new Dictionary>(); var hiddenChannels = 0; - foreach (var channel in await guild.GetChannelsAsync()) + foreach (var channel in await _rest.GetGuildChannels(guild.Id)) { - var botPermissions = channel.BotPermissions(); + var botPermissions = _bot.PermissionsIn(channel.Id); + var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.AuthorNew.Id, senderGuildUser.Roles); - var userPermissions = senderGuildUser.PermissionsIn(channel); - if ((userPermissions & Permissions.AccessChannels) == 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) @@ -154,18 +177,18 @@ namespace PluralKit.Bot { // 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.TryAdd(missingPermissionField, new List()); permissionsMissing[missingPermissionField].Add(channel); } } // Generate the output embed - var eb = new DiscordEmbedBuilder() - .WithTitle($"Permission check for **{guild.Name}**"); + var eb = new EmbedBuilder() + .Title($"Permission check for **{guild.Name}**"); if (permissionsMissing.Count == 0) { - eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(DiscordUtils.Green); + eb.Description($"No errors found, all channels proxyable :)").Color((uint?) DiscordUtils.Green.Value); } else { @@ -173,18 +196,19 @@ namespace PluralKit.Bot { { // Each missing permission field can have multiple missing channels // so we extract them all and generate a comma-separated list + // TODO: port ToPermissionString? var missingPermissionNames = ((Permissions)missingPermissionField).ToPermissionString(); var channelsList = string.Join("\n", channels .OrderBy(c => c.Position) .Select(c => $"#{c.Name}")); - eb.AddField($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000)); - eb.WithColor(DiscordUtils.Red); + eb.Field(new($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000))); + eb.Color((uint?) DiscordUtils.Red.Value); } } if (hiddenChannels > 0) - eb.WithFooter($"{"channel".ToQuantity(hiddenChannels)} were ignored as you do not have view access to them."); + eb.Footer(new($"{"channel".ToQuantity(hiddenChannels)} were ignored as you do not have view access to them.")); // Send! :) await ctx.Reply(embed: eb.Build()); diff --git a/PluralKit.Bot/Commands/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index 507a2ceb..2660df04 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -123,7 +123,7 @@ namespace PluralKit.Bot { if (lastCategory != channel!.ParentId && fieldValue.Length > 0) { - eb.AddField(CategoryName(lastCategory), fieldValue.ToString()); + eb.Field(new(CategoryName(lastCategory), fieldValue.ToString())); fieldValue.Clear(); } else fieldValue.Append("\n"); @@ -132,7 +132,7 @@ namespace PluralKit.Bot lastCategory = channel.ParentId; } - eb.AddField(CategoryName(lastCategory), fieldValue.ToString()); + eb.Field(new(CategoryName(lastCategory), fieldValue.ToString())); return Task.CompletedTask; }); diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index ab95b105..2c96babb 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using DSharpPlus.Entities; +using Myriad.Builders; + using NodaTime; using NodaTime.Text; using NodaTime.TimeZones; @@ -150,7 +152,7 @@ namespace PluralKit.Bot // The attachment's already right there, no need to preview it. var hasEmbed = img.Source != AvatarSource.Attachment; await (hasEmbed - ? ctx.Reply(msg, embed: new DiscordEmbedBuilder().WithImageUrl(img.Url).Build()) + ? ctx.Reply(msg, embed: new EmbedBuilder().Image(new(img.Url)).Build()) : ctx.Reply(msg)); } diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 1f22b88a..47a01d03 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -98,9 +98,11 @@ namespace PluralKit.Bot stringToAdd = $"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago)\n"; } + try // Unfortunately the only way to test DiscordEmbedBuilder.Description max length is this { - builder.Description += stringToAdd; + // TODO: what is this?? + // builder.Description += stringToAdd; } catch (ArgumentException) { diff --git a/PluralKit.Bot/Commands/SystemLink.cs b/PluralKit.Bot/Commands/SystemLink.cs index 70c829dd..0ebc0d83 100644 --- a/PluralKit.Bot/Commands/SystemLink.cs +++ b/PluralKit.Bot/Commands/SystemLink.cs @@ -1,7 +1,8 @@ using System.Linq; using System.Threading.Tasks; -using DSharpPlus.Entities; +using Myriad.Extensions; +using Myriad.Rest.Types; using PluralKit.Core; @@ -33,8 +34,8 @@ namespace PluralKit.Bot if (existingAccount != null) throw Errors.AccountInOtherSystem(existingAccount); - var msg = $"{account.Mention}, please confirm the link by clicking the {Emojis.Success} reaction on this message."; - var mentions = new IMention[] { new UserMention(account) }; + var msg = $"{account.Mention()}, please confirm the link by clicking the {Emojis.Success} reaction on this message."; + var mentions = new AllowedMentions {Users = new[] {account.Id}}; if (!await ctx.PromptYesNo(msg, user: account, mentions: mentions, matchFlag: false)) throw Errors.MemberLinkCancelled; await _repo.AddAccount(conn, ctx.System.Id, account.Id); await ctx.Reply($"{Emojis.Success} Account linked to system."); diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 8dfd189c..7229581d 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -83,7 +83,9 @@ namespace PluralKit.Bot // Event handler queue builder.RegisterType>().AsSelf().SingleInstance(); + builder.RegisterType>().AsSelf().SingleInstance(); builder.RegisterType>().AsSelf().SingleInstance(); + builder.RegisterType>().AsSelf().SingleInstance(); // Bot services builder.RegisterType().AsSelf().SingleInstance(); diff --git a/PluralKit.Bot/Utils/ContextUtils.cs b/PluralKit.Bot/Utils/ContextUtils.cs index 67bb369d..c245d549 100644 --- a/PluralKit.Bot/Utils/ContextUtils.cs +++ b/PluralKit.Bot/Utils/ContextUtils.cs @@ -6,19 +6,17 @@ using System.Threading.Tasks; using Autofac; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; -using DSharpPlus.Exceptions; - +using Myriad.Builders; +using Myriad.Gateway; +using Myriad.Rest.Exceptions; +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; using Myriad.Types; using NodaTime; using PluralKit.Core; -using Permissions = DSharpPlus.Permissions; - namespace PluralKit.Bot { public static class ContextUtils { public static async Task ConfirmClear(this Context ctx, string toClear) @@ -27,52 +25,45 @@ namespace PluralKit.Bot { else return true; } - public static async Task PromptYesNo(this Context ctx, String msgString, DiscordUser user = null, Duration? timeout = null, IEnumerable mentions = null, bool matchFlag = true) + public static async Task PromptYesNo(this Context ctx, string msgString, User user = null, Duration? timeout = null, AllowedMentions mentions = null, bool matchFlag = true) { - DiscordMessage message; + Message message; if (matchFlag && ctx.MatchFlag("y", "yes")) return true; else message = await ctx.Reply(msgString, mentions: mentions); var cts = new CancellationTokenSource(); - if (user == null) user = ctx.Author; + if (user == null) user = ctx.AuthorNew; if (timeout == null) timeout = Duration.FromMinutes(5); // "Fork" the task adding the reactions off so we don't have to wait for them to be finished to start listening for presses - var _ = message.CreateReactionsBulk(new[] {Emojis.Success, Emojis.Error}); + await ctx.RestNew.CreateReactionsBulk(message, new[] {Emojis.Success, Emojis.Error}); - bool ReactionPredicate(MessageReactionAddEventArgs e) + bool ReactionPredicate(MessageReactionAddEvent e) { - if (e.Channel.Id != message.ChannelId || e.Message.Id != message.Id) return false; - if (e.User.Id != user.Id) return false; + if (e.ChannelId != message.ChannelId || e.MessageId != message.Id) return false; + if (e.UserId != user.Id) return false; return true; } - bool MessagePredicate(MessageCreateEventArgs e) + bool MessagePredicate(MessageCreateEvent e) { - if (e.Channel.Id != message.ChannelId) return false; + if (e.ChannelId != message.ChannelId) return false; if (e.Author.Id != user.Id) return false; var strings = new [] {"y", "yes", "n", "no"}; - foreach (var str in strings) - if (e.Message.Content.Equals(str, StringComparison.InvariantCultureIgnoreCase)) - return true; - - return false; + return strings.Any(str => string.Equals(e.Content, str, StringComparison.InvariantCultureIgnoreCase)); } - var messageTask = ctx.Services.Resolve>().WaitFor(MessagePredicate, timeout, cts.Token); - var reactionTask = ctx.Services.Resolve>().WaitFor(ReactionPredicate, timeout, cts.Token); + var messageTask = ctx.Services.Resolve>().WaitFor(MessagePredicate, timeout, cts.Token); + var reactionTask = ctx.Services.Resolve>().WaitFor(ReactionPredicate, timeout, cts.Token); var theTask = await Task.WhenAny(messageTask, reactionTask); cts.Cancel(); if (theTask == messageTask) { - var responseMsg = (await messageTask).Message; + var responseMsg = (await messageTask); var positives = new[] {"y", "yes"}; - foreach (var p in positives) - if (responseMsg.Content.Equals(p, StringComparison.InvariantCultureIgnoreCase)) - return true; - return false; + return positives.Any(p => string.Equals(responseMsg.Content, p, StringComparison.InvariantCultureIgnoreCase)); } if (theTask == reactionTask) @@ -81,50 +72,45 @@ namespace PluralKit.Bot { return false; } - public static async Task AwaitReaction(this Context ctx, DiscordMessage message, DiscordUser user = null, Func predicate = null, TimeSpan? timeout = null) { - var tcs = new TaskCompletionSource(); - Task Inner(DiscordClient _, MessageReactionAddEventArgs args) { - if (message.Id != args.Message.Id) return Task.CompletedTask; // Ignore reactions for different messages - if (user != null && user.Id != args.User.Id) return Task.CompletedTask; // Ignore messages from other users if a user was defined - if (predicate != null && !predicate.Invoke(args)) return Task.CompletedTask; // Check predicate - tcs.SetResult(args); - return Task.CompletedTask; - } - - ctx.Shard.MessageReactionAdded += Inner; - try { - return await tcs.Task.TimeoutAfter(timeout); - } finally { - ctx.Shard.MessageReactionAdded -= Inner; + public static async Task AwaitReaction(this Context ctx, Message message, User user = null, Func predicate = null, Duration? timeout = null) + { + bool ReactionPredicate(MessageReactionAddEvent evt) + { + if (message.Id != evt.MessageId) return false; // Ignore reactions for different messages + if (user != null && user.Id != evt.UserId) return false; // Ignore messages from other users if a user was defined + if (predicate != null && !predicate.Invoke(evt)) return false; // Check predicate + return true; } + + return await ctx.Services.Resolve>().WaitFor(ReactionPredicate, timeout); } public static async Task ConfirmWithReply(this Context ctx, string expectedReply) { - bool Predicate(MessageCreateEventArgs e) => - e.Author == ctx.Author && e.Channel.Id == ctx.Channel.Id; + bool Predicate(MessageCreateEvent e) => + e.Author.Id == ctx.AuthorNew.Id && e.ChannelId == ctx.Channel.Id; - var msg = await ctx.Services.Resolve>() + var msg = await ctx.Services.Resolve>() .WaitFor(Predicate, Duration.FromMinutes(1)); - return string.Equals(msg.Message.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase); + return string.Equals(msg.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase); } - public static async Task Paginate(this Context ctx, IAsyncEnumerable items, int totalCount, int itemsPerPage, string title, Func, Task> renderer) { + public static async Task Paginate(this Context ctx, IAsyncEnumerable items, int totalCount, int itemsPerPage, string title, Func, Task> renderer) { // TODO: make this generic enough we can use it in Choose below var buffer = new List(); await using var enumerator = items.GetAsyncEnumerator(); var pageCount = (int) Math.Ceiling(totalCount / (double) itemsPerPage); - async Task MakeEmbedForPage(int page) + async Task MakeEmbedForPage(int page) { var bufferedItemsNeeded = (page + 1) * itemsPerPage; while (buffer.Count < bufferedItemsNeeded && await enumerator.MoveNextAsync()) buffer.Add(enumerator.Current); - var eb = new DiscordEmbedBuilder(); - eb.Title = pageCount > 1 ? $"[{page+1}/{pageCount}] {title}" : title; + var eb = new EmbedBuilder(); + eb.Title(pageCount > 1 ? $"[{page+1}/{pageCount}] {title}" : title); await renderer(eb, buffer.Skip(page*itemsPerPage).Take(itemsPerPage)); return eb.Build(); } @@ -134,13 +120,13 @@ namespace PluralKit.Bot { var msg = await ctx.Reply(embed: await MakeEmbedForPage(0)); if (pageCount <= 1) return; // If we only have one (or no) page, don't bother with the reaction/pagination logic, lol string[] botEmojis = { "\u23EA", "\u2B05", "\u27A1", "\u23E9", Emojis.Error }; - - var _ = msg.CreateReactionsBulk(botEmojis); // Again, "fork" + + var _ = ctx.RestNew.CreateReactionsBulk(msg, botEmojis); // Again, "fork" try { var currentPage = 0; while (true) { - var reaction = await ctx.AwaitReaction(msg, ctx.Author, timeout: TimeSpan.FromMinutes(5)); + var reaction = await ctx.AwaitReaction(msg, ctx.AuthorNew, timeout: Duration.FromMinutes(5)); // Increment/decrement page counter based on which reaction was clicked if (reaction.Emoji.Name == "\u23EA") currentPage = 0; // << @@ -154,18 +140,18 @@ namespace PluralKit.Bot { // If we can, remove the user's reaction (so they can press again quickly) if (ctx.BotPermissions.HasFlag(PermissionSet.ManageMessages)) - await msg.DeleteReactionAsync(reaction.Emoji, reaction.User); + await ctx.RestNew.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, reaction.UserId); // Edit the embed with the new page var embed = await MakeEmbedForPage(currentPage); - await msg.ModifyAsync(embed: embed); + await ctx.RestNew.EditMessage(msg.ChannelId, msg.Id, new MessageEditRequest {Embed = embed}); } } catch (TimeoutException) { // "escape hatch", clean up as if we hit X } if (ctx.BotPermissions.HasFlag(PermissionSet.ManageMessages)) - await msg.DeleteAllReactionsAsync(); + await ctx.RestNew.DeleteAllReactions(msg.ChannelId, msg.Id); } // If we get a "NotFound" error, the message has been deleted and thus not our problem catch (NotFoundException) { } @@ -203,9 +189,10 @@ namespace PluralKit.Bot { // Add back/forward reactions and the actual indicator emojis async Task AddEmojis() { - await msg.CreateReactionAsync(DiscordEmoji.FromUnicode("\u2B05")); - await msg.CreateReactionAsync(DiscordEmoji.FromUnicode("\u27A1")); - for (int i = 0; i < items.Count; i++) await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(indicators[i])); + await ctx.RestNew.CreateReaction(msg.ChannelId, msg.Id, new() { Name = "\u2B05" }); + await ctx.RestNew.CreateReaction(msg.ChannelId, msg.Id, new() { Name = "\u27A1" }); + for (int i = 0; i < items.Count; i++) + await ctx.RestNew.CreateReaction(msg.ChannelId, msg.Id, new() { Name = indicators[i] }); } var _ = AddEmojis(); // Not concerned about awaiting @@ -213,7 +200,7 @@ namespace PluralKit.Bot { while (true) { // Wait for a reaction - var reaction = await ctx.AwaitReaction(msg, ctx.Author); + var reaction = await ctx.AwaitReaction(msg, ctx.AuthorNew); // If it's a movement reaction, inc/dec the page index if (reaction.Emoji.Name == "\u2B05") currPage -= 1; // < @@ -230,8 +217,13 @@ namespace PluralKit.Bot { if (idx < items.Count) return items[idx]; } - var __ = msg.DeleteReactionAsync(reaction.Emoji, ctx.Author); // don't care about awaiting - await msg.ModifyAsync($"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}"); + var __ = ctx.RestNew.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, ctx.Author.Id); + await ctx.RestNew.EditMessage(msg.ChannelId, msg.Id, + new() + { + Content = + $"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}" + }); } } else @@ -241,13 +233,14 @@ namespace PluralKit.Bot { // Add the relevant reactions (we don't care too much about awaiting) async Task AddEmojis() { - for (int i = 0; i < items.Count; i++) await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(indicators[i])); + for (int i = 0; i < items.Count; i++) + await ctx.RestNew.CreateReaction(msg.ChannelId, msg.Id, new() {Name = indicators[i]}); } var _ = AddEmojis(); // Then wait for a reaction and return whichever one we found - var reaction = await ctx.AwaitReaction(msg, ctx.Author,rx => indicators.Contains(rx.Emoji.Name)); + var reaction = await ctx.AwaitReaction(msg, ctx.AuthorNew,rx => indicators.Contains(rx.Emoji.Name)); return items[Array.IndexOf(indicators, reaction.Emoji.Name)]; } } @@ -271,12 +264,12 @@ namespace PluralKit.Bot { try { - await Task.WhenAll(ctx.Message.CreateReactionAsync(DiscordEmoji.FromUnicode(emoji)), task); + await Task.WhenAll(ctx.RestNew.CreateReaction(ctx.MessageNew.ChannelId, ctx.MessageNew.Id, new() {Name = emoji}), task); return await task; } finally { - var _ = ctx.Message.DeleteReactionAsync(DiscordEmoji.FromUnicode(emoji), ctx.Shard.CurrentUser); + var _ = ctx.RestNew.DeleteOwnReaction(ctx.MessageNew.ChannelId, ctx.MessageNew.Id, new() { Name = emoji }); } } } diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index ee3e391d..281324e8 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -12,7 +12,9 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using DSharpPlus.Exceptions; +using Myriad.Builders; using Myriad.Extensions; +using Myriad.Rest; using Myriad.Rest.Types; using Myriad.Types; @@ -116,11 +118,11 @@ namespace PluralKit.Bot public static ulong InstantToSnowflake(DateTimeOffset time) => (ulong) (time - new DateTimeOffset(2015, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalMilliseconds << 22; - public static async Task CreateReactionsBulk(this DiscordMessage msg, string[] reactions) + public static async Task CreateReactionsBulk(this DiscordApiClient rest, Message msg, string[] reactions) { foreach (var reaction in reactions) { - await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(reaction)); + await rest.CreateReaction(msg.ChannelId, msg.Id, new() {Name = reaction}); } } @@ -329,7 +331,7 @@ namespace PluralKit.Bot } } - public static DiscordEmbedBuilder WithSimpleLineContent(this DiscordEmbedBuilder eb, IEnumerable lines) + public static EmbedBuilder WithSimpleLineContent(this EmbedBuilder eb, IEnumerable lines) { static int CharacterLimit(int pageNumber) => // First chunk goes in description (2048 chars), rest go in embed values (1000 chars) @@ -340,11 +342,11 @@ namespace PluralKit.Bot // Add the first page to the embed description if (pages.Count > 0) - eb.WithDescription(pages[0]); + eb.Description(pages[0]); // Add the rest to blank-named (\u200B) fields for (var i = 1; i < pages.Count; i++) - eb.AddField("\u200B", pages[i]); + eb.Field(new("\u200B", pages[i])); return eb; } diff --git a/PluralKit.Core/Utils/HandlerQueue.cs b/PluralKit.Core/Utils/HandlerQueue.cs index 9d261c0a..b114e679 100644 --- a/PluralKit.Core/Utils/HandlerQueue.cs +++ b/PluralKit.Core/Utils/HandlerQueue.cs @@ -10,7 +10,7 @@ namespace PluralKit.Core public class HandlerQueue { private long _seq; - private readonly ConcurrentDictionary _handlers = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _handlers = new(); public async Task WaitFor(Func predicate, Duration? timeout = null, CancellationToken ct = default) { From 9d919d687b4d58923d4dc2f4ed8f8a282c896ffb Mon Sep 17 00:00:00 2001 From: Ske Date: Thu, 24 Dec 2020 14:54:45 +0100 Subject: [PATCH 05/26] GH Build with .NET 5 --- .github/workflows/dotnetcore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 6091e9df..3cd0fb37 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -14,6 +14,6 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 3.1.100 + dotnet-version: 5.0.x - name: Build and test with dotnet run: dotnet test --configuration Release From 2e0c30eb5d495f189f4cea16161ca8a9b3502930 Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 25 Dec 2020 12:56:46 +0100 Subject: [PATCH 06/26] Port some more commands, mostly for embeds --- Myriad/Rest/Ratelimit/Bucket.cs | 11 ++- Myriad/Rest/Ratelimit/BucketManager.cs | 1 + .../Commands/Avatars/ContextAvatarExt.cs | 2 +- PluralKit.Bot/Commands/Groups.cs | 54 +++++++------- PluralKit.Bot/Commands/Help.cs | 24 +++--- PluralKit.Bot/Commands/ImportExport.cs | 11 +-- PluralKit.Bot/Commands/Member.cs | 12 +-- PluralKit.Bot/Commands/MemberAvatar.cs | 10 +-- PluralKit.Bot/Commands/MemberEdit.cs | 73 ++++++++++--------- PluralKit.Bot/Commands/MemberGroup.cs | 4 +- PluralKit.Bot/Commands/ServerConfig.cs | 16 ++-- PluralKit.Bot/Commands/SystemEdit.cs | 34 ++++----- PluralKit.Bot/Commands/Token.cs | 23 +++--- 13 files changed, 140 insertions(+), 135 deletions(-) diff --git a/Myriad/Rest/Ratelimit/Bucket.cs b/Myriad/Rest/Ratelimit/Bucket.cs index 6210ce89..e9d0eb5f 100644 --- a/Myriad/Rest/Ratelimit/Bucket.cs +++ b/Myriad/Rest/Ratelimit/Bucket.cs @@ -17,6 +17,7 @@ namespace Myriad.Rest.Ratelimit private DateTimeOffset _nextReset; private bool _resetTimeValid; + private bool _hasReceivedRemaining; public Bucket(ILogger logger, string key, ulong major, int limit) { @@ -77,8 +78,8 @@ namespace Myriad.Rest.Ratelimit var headerNextReset = DateTimeOffset.UtcNow + headers.ResetAfter.Value; // todo: server time if (headerNextReset > _nextReset) { - _logger.Debug("{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server (after: {NextResetAfter})", - Key, Major, headerNextReset, headers.ResetAfter.Value); + _logger.Debug("{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server (after: {NextResetAfter}, new remaining: {Remaining})", + Key, Major, headerNextReset, headers.ResetAfter.Value, headers.Remaining); _nextReset = headerNextReset; _resetTimeValid = true; @@ -87,6 +88,12 @@ namespace Myriad.Rest.Ratelimit if (headers.Limit != null) Limit = headers.Limit.Value; + + if (headers.Remaining != null && !_hasReceivedRemaining) + { + _hasReceivedRemaining = true; + Remaining = headers.Remaining.Value; + } } finally { diff --git a/Myriad/Rest/Ratelimit/BucketManager.cs b/Myriad/Rest/Ratelimit/BucketManager.cs index b5326903..edea0825 100644 --- a/Myriad/Rest/Ratelimit/BucketManager.cs +++ b/Myriad/Rest/Ratelimit/BucketManager.cs @@ -44,6 +44,7 @@ namespace Myriad.Rest.Ratelimit 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)); } diff --git a/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs b/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs index 43207639..98646da2 100644 --- a/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs +++ b/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs @@ -45,7 +45,7 @@ namespace PluralKit.Bot } // If we have an attachment, use that - if (ctx.Message.Attachments.FirstOrDefault() is {} attachment) + if (ctx.MessageNew.Attachments.FirstOrDefault() is {} attachment) { var url = TryRewriteCdnUrl(attachment.ProxyUrl); return new ParsedImage {Url = url, Source = AvatarSource.Attachment}; diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 36ee74fb..ce93f8fe 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -6,8 +6,6 @@ using System.Threading.Tasks; using Dapper; -using DSharpPlus.Entities; - using Humanizer; using Myriad.Builders; @@ -56,12 +54,12 @@ namespace PluralKit.Bot var newGroup = await _repo.CreateGroup(conn, ctx.System.Id, groupName); - var eb = new DiscordEmbedBuilder() - .WithDescription($"Your new group, **{groupName}**, has been created, with the group ID **`{newGroup.Hid}`**.\nBelow are a couple of useful commands:") - .AddField("View the group card", $"> pk;group **{newGroup.Reference()}**") - .AddField("Add members to the group", $"> pk;group **{newGroup.Reference()}** add **MemberName**\n> pk;group **{newGroup.Reference()}** add **Member1** **Member2** **Member3** (and so on...)") - .AddField("Set the description", $"> pk;group **{newGroup.Reference()}** description **This is my new group, and here is the description!**") - .AddField("Set the group icon", $"> pk;group **{newGroup.Reference()}** icon\n*(with an image attached)*"); + 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()); } @@ -103,12 +101,12 @@ namespace PluralKit.Bot else if (!ctx.HasNext()) { // No perms check, display name isn't covered by member privacy - var eb = new DiscordEmbedBuilder() - .AddField("Name", target.Name) - .AddField("Display Name", target.DisplayName ?? "*(none)*"); + var eb = new EmbedBuilder() + .Field(new("Name", target.Name)) + .Field(new("Display Name", target.DisplayName ?? "*(none)*")); if (ctx.System?.Id == target.System) - eb.WithDescription($"To change display name, type `pk;group {target.Reference()} displayname `.\nTo clear it, type `pk;group {target.Reference()} displayname -clear`."); + eb.Description($"To change display name, type `pk;group {target.Reference()} displayname `.\nTo clear it, type `pk;group {target.Reference()} displayname -clear`."); await ctx.Reply(embed: eb.Build()); } @@ -145,11 +143,11 @@ namespace PluralKit.Bot else if (ctx.MatchFlag("r", "raw")) await ctx.Reply($"```\n{target.Description}\n```"); else - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("Group description") - .WithDescription(target.Description) - .AddField("\u200B", $"To print the description with formatting, type `pk;group {target.Reference()} description -raw`." - + (ctx.System?.Id == target.System ? $" To clear it, type `pk;group {target.Reference()} description -clear`." : "")) + 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()); } else @@ -204,13 +202,13 @@ namespace PluralKit.Bot { if ((target.Icon?.Trim() ?? "").Length > 0) { - var eb = new DiscordEmbedBuilder() - .WithTitle("Group icon") - .WithImageUrl(target.Icon); + var eb = new EmbedBuilder() + .Title("Group icon") + .Image(new(target.Icon)); if (target.System == ctx.System?.Id) { - eb.WithDescription($"To clear, use `pk;group {target.Reference()} icon -clear`."); + eb.Description($"To clear, use `pk;group {target.Reference()} icon -clear`."); } await ctx.Reply(embed: eb.Build()); @@ -359,13 +357,13 @@ namespace PluralKit.Bot // Display privacy settings if (!ctx.HasNext() && newValueFromCommand == null) { - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle($"Current privacy settings for {target.Name}") - .AddField("Description", target.DescriptionPrivacy.Explanation()) - .AddField("Icon", target.IconPrivacy.Explanation()) - .AddField("Member list", target.ListPrivacy.Explanation()) - .AddField("Visibility", target.Visibility.Explanation()) - .WithDescription($"To edit privacy settings, use the command:\n> pk;group **{target.Reference()}** privacy **** ****\n\n- `subject` is one of `description`, `icon`, `members`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + 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; } diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index 20b41c85..dff1bf33 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -using DSharpPlus.Entities; +using Myriad.Builders; using PluralKit.Core; @@ -10,17 +10,17 @@ namespace PluralKit.Bot { public async Task HelpRoot(Context ctx) { - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("PluralKit") - .WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.") - .AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.") - .AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation.") - .AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the Getting Started guide](https://pluralkit.me/start) for more information.") - .AddField("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nReact with {Emojis.Bell} on a proxied message to \"ping\" the sender\nType **`pk;invite`** to get a link to invite this bot to your own server!") - .AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") - .AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78") - .WithFooter($"By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/") - .WithColor(DiscordUtils.Blue) + 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((uint?) DiscordUtils.Blue.Value) .Build()); } diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index 9003d1dc..6d79f1bf 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -5,9 +5,9 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; +using Myriad.Rest.Exceptions; + using Newtonsoft.Json; -using DSharpPlus.Exceptions; -using DSharpPlus.Entities; using Newtonsoft.Json.Linq; @@ -18,7 +18,7 @@ namespace PluralKit.Bot public class ImportExport { private readonly DataFileService _dataFiles; - private readonly JsonSerializerSettings _settings = new JsonSerializerSettings + private readonly JsonSerializerSettings _settings = new() { // Otherwise it'll mess up/reformat the ISO strings for ???some??? reason >.> DateParseHandling = DateParseHandling.None @@ -145,8 +145,9 @@ namespace PluralKit.Bot await dm.SendMessageAsync($"<{msg.Attachments[0].Url}>"); // If the original message wasn't posted in DMs, send a public reminder - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Success} Check your DMs!"); + // TODO: DMs + // if (!(ctx.Channel is DiscordDmChannel)) + // await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) { diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 68c2830e..59fce5cd 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -7,6 +7,8 @@ using Dapper; using DSharpPlus.Entities; +using Myriad.Builders; + using Newtonsoft.Json.Linq; using PluralKit.Core; @@ -89,11 +91,11 @@ namespace PluralKit.Bot var data = JObject.Parse(await resp.Content.ReadAsStringAsync()); var scream = data["soulscream"]!.Value(); - var eb = new DiscordEmbedBuilder() - .WithColor(DiscordColor.Red) - .WithTitle(name) - .WithUrl($"https://onomancer.sibr.dev/reflect?name={encoded}") - .WithDescription($"*{scream}*"); + var eb = new EmbedBuilder() + .Color((uint?) DiscordColor.Red.Value) + .Title(name) + .Url($"https://onomancer.sibr.dev/reflect?name={encoded}") + .Description($"*{scream}*"); await ctx.Reply(embed: eb.Build()); } } diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 65ad0b56..fa08b4b6 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -2,8 +2,6 @@ using System; using System.Threading.Tasks; -using DSharpPlus.Entities; - using Myriad.Builders; using PluralKit.Core; @@ -60,11 +58,11 @@ namespace PluralKit.Bot var field = location == AvatarLocation.Server ? $"server avatar (for {ctx.GuildNew.Name})" : "avatar"; var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar"; - var eb = new DiscordEmbedBuilder() - .WithTitle($"{target.NameFor(ctx)}'s {field}") - .WithImageUrl(currentValue); + var eb = new EmbedBuilder() + .Title($"{target.NameFor(ctx)}'s {field}") + .Image(new(currentValue)); if (target.System == ctx.System?.Id) - eb.WithDescription($"To clear, use `pk;member {target.Reference()} {cmd} clear`."); + eb.Description($"To clear, use `pk;member {target.Reference()} {cmd} clear`."); await ctx.Reply(embed: eb.Build()); } diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 710269d6..017441d0 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -2,7 +2,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using System; -using DSharpPlus.Entities; +using Myriad.Builders; using NodaTime; @@ -75,11 +75,11 @@ namespace PluralKit.Bot else if (ctx.MatchFlag("r", "raw")) await ctx.Reply($"```\n{target.Description}\n```"); else - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("Member description") - .WithDescription(target.Description) - .AddField("\u200B", $"To print the description with formatting, type `pk;member {target.Reference()} description -raw`." - + (ctx.System?.Id == target.System ? $" To clear it, type `pk;member {target.Reference()} description -clear`." : "")) + 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()); } else @@ -158,11 +158,11 @@ namespace PluralKit.Bot else await ctx.Reply("This member does not have a color set."); else - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("Member color") - .WithColor(target.Color.ToDiscordColor().Value) - .WithThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20") - .WithDescription($"This member's color is **#{target.Color}**." + await ctx.Reply(embed: new EmbedBuilder() + .Title("Member color") + .Color((uint?) target.Color.ToDiscordColor()!.Value.Value) + .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()); } @@ -176,10 +176,10 @@ namespace PluralKit.Bot var patch = new MemberPatch {Color = Partial.Present(color.ToLowerInvariant())}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle($"{Emojis.Success} Member color changed.") - .WithColor(color.ToDiscordColor().Value) - .WithThumbnail($"https://fakeimg.pl/256x256/{color}/?text=%20") + await ctx.Reply(embed: new EmbedBuilder() + .Title($"{Emojis.Success} Member color changed.") + .Color((uint?) color.ToDiscordColor()!.Value.Value) + .Thumbnail(new($"https://fakeimg.pl/256x256/{color}/?text=%20")) .Build()); } } @@ -221,7 +221,7 @@ namespace PluralKit.Bot } } - private async Task CreateMemberNameInfoEmbed(Context ctx, PKMember target) + private async Task CreateMemberNameInfoEmbed(Context ctx, PKMember target) { var lcx = ctx.LookupContextFor(target); @@ -229,28 +229,29 @@ namespace PluralKit.Bot if (ctx.GuildNew != null) memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); - var eb = new DiscordEmbedBuilder().WithTitle($"Member names") - .WithFooter($"Member ID: {target.Hid} | Active name in bold. Server name overrides display name, which overrides base name."); + 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.AddField("Name", $"**{target.NameFor(ctx)}**"); + eb.Field(new("Name", $"**{target.NameFor(ctx)}**")); else - eb.AddField("Name", target.NameFor(ctx)); + eb.Field(new("Name", target.NameFor(ctx))); if (target.NamePrivacy.CanAccess(lcx)) { if (target.DisplayName != null && memberGuildConfig?.DisplayName == null) - eb.AddField("Display Name", $"**{target.DisplayName}**"); + eb.Field(new("Display Name", $"**{target.DisplayName}**")); else - eb.AddField("Display Name", target.DisplayName ?? "*(none)*"); + eb.Field(new("Display Name", target.DisplayName ?? "*(none)*")); } if (ctx.GuildNew != null) { if (memberGuildConfig?.DisplayName != null) - eb.AddField($"Server Name (in {ctx.GuildNew.Name})", $"**{memberGuildConfig.DisplayName}**"); + eb.Field(new($"Server Name (in {ctx.GuildNew.Name})", $"**{memberGuildConfig.DisplayName}**")); else - eb.AddField($"Server Name (in {ctx.GuildNew.Name})", memberGuildConfig?.DisplayName ?? "*(none)*"); + eb.Field(new($"Server Name (in {ctx.GuildNew.Name})", memberGuildConfig?.DisplayName ?? "*(none)*")); } return eb; @@ -285,7 +286,7 @@ namespace PluralKit.Bot // No perms check, display name isn't covered by member privacy var eb = await CreateMemberNameInfoEmbed(ctx, target); if (ctx.System?.Id == target.System) - eb.WithDescription($"To change display name, type `pk;member {target.Reference()} displayname `.\nTo clear it, type `pk;member {target.Reference()} displayname -clear`."); + eb.Description($"To change display name, type `pk;member {target.Reference()} displayname `.\nTo clear it, type `pk;member {target.Reference()} displayname -clear`."); await ctx.Reply(embed: eb.Build()); } else @@ -322,7 +323,7 @@ namespace PluralKit.Bot // No perms check, server name isn't covered by member privacy var eb = await CreateMemberNameInfoEmbed(ctx, target); if (ctx.System?.Id == target.System) - eb.WithDescription($"To change server name, type `pk;member {target.Reference()} servername `.\nTo clear it, type `pk;member {target.Reference()} servername -clear`."); + eb.Description($"To change server name, type `pk;member {target.Reference()} servername `.\nTo clear it, type `pk;member {target.Reference()} servername -clear`."); await ctx.Reply(embed: eb.Build()); } else @@ -398,16 +399,16 @@ namespace PluralKit.Bot // Display privacy settings if (!ctx.HasNext() && newValueFromCommand == null) { - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle($"Current privacy settings for {target.NameFor(ctx)}") - .AddField("Name (replaces name with display name if member has one)",target.NamePrivacy.Explanation()) - .AddField("Description", target.DescriptionPrivacy.Explanation()) - .AddField("Avatar", target.AvatarPrivacy.Explanation()) - .AddField("Birthday", target.BirthdayPrivacy.Explanation()) - .AddField("Pronouns", target.PronounPrivacy.Explanation()) - .AddField("Meta (message count, last front, last message)",target.MetadataPrivacy.Explanation()) - .AddField("Visibility", target.MemberVisibility.Explanation()) - .WithDescription("To edit privacy settings, use the command:\n`pk;member privacy `\n\n- `subject` is one of `name`, `description`, `avatar`, `birthday`, `pronouns`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + 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; } diff --git a/PluralKit.Bot/Commands/MemberGroup.cs b/PluralKit.Bot/Commands/MemberGroup.cs index 6a8f9f7b..7d19e5e7 100644 --- a/PluralKit.Bot/Commands/MemberGroup.cs +++ b/PluralKit.Bot/Commands/MemberGroup.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using DSharpPlus.Entities; +using Myriad.Builders; using PluralKit.Core; @@ -84,7 +84,7 @@ namespace PluralKit.Bot msg += $"\nTo remove this member from one or more groups, use `pk;m {target.Reference()} group remove [group 2] [group 3...]`"; } - await ctx.Reply(msg, embed: (new DiscordEmbedBuilder().WithTitle($"{target.Name}'s groups").WithDescription(description)).Build()); + 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/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index 2660df04..6bcbb5b0 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -3,17 +3,13 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.Entities; - +using Myriad.Builders; using Myriad.Cache; using Myriad.Extensions; using Myriad.Types; using PluralKit.Core; -using Permissions = DSharpPlus.Permissions; - namespace PluralKit.Bot { public class ServerConfig @@ -183,15 +179,15 @@ namespace PluralKit.Bot newValue = false; else { - var eb = new DiscordEmbedBuilder() - .WithTitle("Log cleanup settings") - .AddField("Supported bots", botList); + var eb = new EmbedBuilder() + .Title("Log cleanup settings") + .Field(new("Supported bots", botList)); var guildCfg = await _db.Execute(c => _repo.GetGuild(c, ctx.GuildNew.Id)); if (guildCfg.LogCleanupEnabled) - eb.WithDescription("Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`."); + eb.Description("Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`."); else - eb.WithDescription("Log cleanup is currently **off** for this server. To enable it, type `pk;logclean on`."); + eb.Description("Log cleanup is currently **off** for this server. To enable it, type `pk;logclean on`."); await ctx.Reply(embed: eb.Build()); return; } diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index 2c96babb..4f492ea2 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -2,8 +2,6 @@ using System; using System.Linq; using System.Threading.Tasks; -using DSharpPlus.Entities; - using Myriad.Builders; using NodaTime; @@ -77,10 +75,10 @@ namespace PluralKit.Bot else if (ctx.MatchFlag("r", "raw")) await ctx.Reply($"```\n{ctx.System.Description}\n```"); else - await ctx.Reply(embed: new DiscordEmbedBuilder() - .WithTitle("System description") - .WithDescription(ctx.System.Description) - .WithFooter("To print the description with formatting, type `pk;s description -raw`. To clear it, type `pk;s description -clear`. To change it, type `pk;s description `.") + 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()); } else @@ -160,10 +158,10 @@ namespace PluralKit.Bot { if ((ctx.System.AvatarUrl?.Trim() ?? "").Length > 0) { - var eb = new DiscordEmbedBuilder() - .WithTitle("System icon") - .WithImageUrl(ctx.System.AvatarUrl) - .WithDescription("To clear, use `pk;system icon clear`."); + var eb = new EmbedBuilder() + .Title("System icon") + .Image(new(ctx.System.AvatarUrl)) + .Description("To clear, use `pk;system icon clear`."); await ctx.Reply(embed: eb.Build()); } else @@ -257,14 +255,14 @@ namespace PluralKit.Bot Task PrintEmbed() { - var eb = new DiscordEmbedBuilder() - .WithTitle("Current privacy settings for your system") - .AddField("Description", ctx.System.DescriptionPrivacy.Explanation()) - .AddField("Member list", ctx.System.MemberListPrivacy.Explanation()) - .AddField("Group list", ctx.System.GroupListPrivacy.Explanation()) - .AddField("Current fronter(s)", ctx.System.FrontPrivacy.Explanation()) - .AddField("Front/switch history", ctx.System.FrontHistoryPrivacy.Explanation()) - .WithDescription("To edit privacy settings, use the command:\n`pk;system privacy `\n\n- `subject` is one of `description`, `list`, `front`, `fronthistory`, `groups`, or `all` \n- `level` is either `public` or `private`."); + 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()); } diff --git a/PluralKit.Bot/Commands/Token.cs b/PluralKit.Bot/Commands/Token.cs index e6a26cb9..00bc8f63 100644 --- a/PluralKit.Bot/Commands/Token.cs +++ b/PluralKit.Bot/Commands/Token.cs @@ -1,7 +1,6 @@ using System.Threading.Tasks; -using DSharpPlus.Entities; -using DSharpPlus.Exceptions; +using Myriad.Rest.Exceptions; using PluralKit.Core; @@ -33,14 +32,16 @@ namespace PluralKit.Bot await dm.SendMessageFixedAsync(token); // If we're not already in a DM, reply with a reminder to check - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Success} Check your DMs!"); + // TODO: DMs + // if (!(ctx.Channel is DiscordDmChannel)) + // await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) { // Can't check for permission errors beforehand, so have to handle here :/ - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); + // TODO: DMs + // if (!(ctx.Channel is DiscordDmChannel)) + // await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); } } @@ -74,14 +75,16 @@ namespace PluralKit.Bot await dm.SendMessageFixedAsync(token); // If we're not already in a DM, reply with a reminder to check - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Success} Check your DMs!"); + // TODO: DMs + // if (!(ctx.Channel is DiscordDmChannel)) + // await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) { // Can't check for permission errors beforehand, so have to handle here :/ - if (!(ctx.Channel is DiscordDmChannel)) - await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); + // TODO: DMs + // if (!(ctx.Channel is DiscordDmChannel)) + // await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); } } } From a2c8cbb5605bdc9783c2441265ebed65633483a1 Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 25 Dec 2020 13:19:35 +0100 Subject: [PATCH 07/26] Add DM support --- Myriad/Cache/DiscordCacheExtensions.cs | 11 ----- Myriad/Cache/IDiscordCache.cs | 1 + Myriad/Cache/MemoryDiscordCache.cs | 38 ++++++++++------- Myriad/Extensions/CacheExtensions.cs | 23 ++++++++++ Myriad/Rest/DiscordApiClient.cs | 3 ++ Myriad/Rest/Types/Requests/CreateDmRequest.cs | 4 ++ Myriad/Types/Channel.cs | 1 + .../ContextEntityArgumentsExt.cs | 1 + PluralKit.Bot/Commands/Token.cs | 42 ++++++++++--------- 9 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 Myriad/Rest/Types/Requests/CreateDmRequest.cs diff --git a/Myriad/Cache/DiscordCacheExtensions.cs b/Myriad/Cache/DiscordCacheExtensions.cs index b4165987..e50c3453 100644 --- a/Myriad/Cache/DiscordCacheExtensions.cs +++ b/Myriad/Cache/DiscordCacheExtensions.cs @@ -55,16 +55,5 @@ namespace Myriad.Cache foreach (var mention in evt.Mentions) await cache.SaveUser(mention); } - - public static async ValueTask GetOrFetchUser(this IDiscordCache cache, DiscordApiClient rest, ulong userId) - { - if (cache.TryGetUser(userId, out var cacheUser)) - return cacheUser; - - var restUser = await rest.GetUser(userId); - if (restUser != null) - await cache.SaveUser(restUser); - return restUser; - } } } \ No newline at end of file diff --git a/Myriad/Cache/IDiscordCache.cs b/Myriad/Cache/IDiscordCache.cs index 7c72b272..c778ed32 100644 --- a/Myriad/Cache/IDiscordCache.cs +++ b/Myriad/Cache/IDiscordCache.cs @@ -19,6 +19,7 @@ namespace Myriad.Cache public bool TryGetGuild(ulong guildId, out Guild guild); public bool TryGetChannel(ulong channelId, out Channel channel); + public bool TryGetDmChannel(ulong userId, out Channel channel); public bool TryGetUser(ulong userId, out User user); public bool TryGetRole(ulong roleId, out Role role); diff --git a/Myriad/Cache/MemoryDiscordCache.cs b/Myriad/Cache/MemoryDiscordCache.cs index 2a6c194f..2dcfde6a 100644 --- a/Myriad/Cache/MemoryDiscordCache.cs +++ b/Myriad/Cache/MemoryDiscordCache.cs @@ -10,19 +10,12 @@ namespace Myriad.Cache { public class MemoryDiscordCache: IDiscordCache { - private readonly ConcurrentDictionary _channels; - private readonly ConcurrentDictionary _guilds; - private readonly ConcurrentDictionary _roles; - private readonly ConcurrentDictionary _users; - - public MemoryDiscordCache() - { - _guilds = new ConcurrentDictionary(); - _channels = new ConcurrentDictionary(); - _users = new ConcurrentDictionary(); - _roles = new ConcurrentDictionary(); - } - + 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(); + public ValueTask SaveGuild(Guild guild) { SaveGuildRaw(guild); @@ -35,14 +28,21 @@ namespace Myriad.Cache return default; } - public ValueTask SaveChannel(Channel channel) + 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); - return default; + if (channel.Recipients != null) + { + foreach (var recipient in channel.Recipients) + { + _dmChannels[recipient.Id] = channel.Id; + await SaveUser(recipient); + } + } } public ValueTask SaveUser(User user) @@ -125,6 +125,14 @@ namespace Myriad.Cache public bool TryGetChannel(ulong channelId, out Channel channel) => _channels.TryGetValue(channelId, out channel!); + public bool TryGetDmChannel(ulong userId, out Channel channel) + { + channel = default!; + if (!_dmChannels.TryGetValue(userId, out var channelId)) + return false; + return TryGetChannel(channelId, out channel); + } + public bool TryGetUser(ulong userId, out User user) => _users.TryGetValue(userId, out user!); diff --git a/Myriad/Extensions/CacheExtensions.cs b/Myriad/Extensions/CacheExtensions.cs index 260c5932..5686606e 100644 --- a/Myriad/Extensions/CacheExtensions.cs +++ b/Myriad/Extensions/CacheExtensions.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; +using System.Threading.Tasks; using Myriad.Cache; +using Myriad.Rest; using Myriad.Types; namespace Myriad.Extensions @@ -41,5 +43,26 @@ namespace Myriad.Extensions throw new KeyNotFoundException($"User {roleId} not found in cache"); return role; } + + public static async ValueTask GetOrFetchUser(this IDiscordCache cache, DiscordApiClient rest, ulong userId) + { + if (cache.TryGetUser(userId, out var cacheUser)) + return cacheUser; + + var restUser = await rest.GetUser(userId); + if (restUser != null) + await cache.SaveUser(restUser); + return restUser; + } + + public static async Task GetOrCreateDmChannel(this IDiscordCache cache, DiscordApiClient rest, ulong recipientId) + { + if (cache.TryGetDmChannel(recipientId, out var cacheChannel)) + return cacheChannel; + + var restChannel = await rest.CreateDm(recipientId); + await cache.SaveChannel(restChannel); + return restChannel; + } } } \ No newline at end of file diff --git a/Myriad/Rest/DiscordApiClient.cs b/Myriad/Rest/DiscordApiClient.cs index 953ce2d0..11bdddb9 100644 --- a/Myriad/Rest/DiscordApiClient.cs +++ b/Myriad/Rest/DiscordApiClient.cs @@ -120,6 +120,9 @@ namespace Myriad.Rest _client.PostMultipart($"/webhooks/{webhookId}/{webhookToken}?wait=true", ("ExecuteWebhook", webhookId), request, files)!; + public Task CreateDm(ulong recipientId) => + _client.Post($"/users/@me/channels", ("CreateDM", default), new CreateDmRequest(recipientId))!; + private static string EncodeEmoji(Emoji emoji) => WebUtility.UrlEncode(emoji.Name) ?? emoji.Id?.ToString() ?? throw new ArgumentException("Could not encode emoji"); diff --git a/Myriad/Rest/Types/Requests/CreateDmRequest.cs b/Myriad/Rest/Types/Requests/CreateDmRequest.cs new file mode 100644 index 00000000..f28b2fe2 --- /dev/null +++ b/Myriad/Rest/Types/Requests/CreateDmRequest.cs @@ -0,0 +1,4 @@ +namespace Myriad.Rest.Types.Requests +{ + public record CreateDmRequest(ulong RecipientId); +} \ No newline at end of file diff --git a/Myriad/Types/Channel.cs b/Myriad/Types/Channel.cs index 2ac13cc6..841a2e1d 100644 --- a/Myriad/Types/Channel.cs +++ b/Myriad/Types/Channel.cs @@ -22,6 +22,7 @@ public bool? Nsfw { get; init; } public ulong? ParentId { get; init; } public Overwrite[]? PermissionOverwrites { get; init; } + public User[]? Recipients { get; init; } public record Overwrite { diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index cb915c99..136b75d2 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Myriad.Cache; +using Myriad.Extensions; using Myriad.Types; using PluralKit.Bot.Utils; diff --git a/PluralKit.Bot/Commands/Token.cs b/PluralKit.Bot/Commands/Token.cs index 00bc8f63..2c34fe38 100644 --- a/PluralKit.Bot/Commands/Token.cs +++ b/PluralKit.Bot/Commands/Token.cs @@ -1,6 +1,9 @@ using System.Threading.Tasks; +using Myriad.Extensions; using Myriad.Rest.Exceptions; +using Myriad.Rest.Types.Requests; +using Myriad.Types; using PluralKit.Core; @@ -26,22 +29,22 @@ namespace PluralKit.Bot try { // DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile) - var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); - await dm.SendMessageFixedAsync( - $"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:"); - await dm.SendMessageFixedAsync(token); + var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.RestNew, ctx.AuthorNew.Id); + await ctx.RestNew.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:" + }); + await ctx.RestNew.CreateMessage(dm.Id, new MessageRequest {Content = token}); // If we're not already in a DM, reply with a reminder to check - // TODO: DMs - // if (!(ctx.Channel is DiscordDmChannel)) - // await ctx.Reply($"{Emojis.Success} Check your DMs!"); + if (ctx.ChannelNew.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) { // Can't check for permission errors beforehand, so have to handle here :/ - // TODO: DMs - // if (!(ctx.Channel is DiscordDmChannel)) - // await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); + if (ctx.ChannelNew.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); } } @@ -66,25 +69,26 @@ namespace PluralKit.Bot try { // DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile) - var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); - await dm.SendMessageFixedAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:"); + var dm = await ctx.Cache.GetOrCreateDmChannel(ctx.RestNew, ctx.AuthorNew.Id); + await ctx.RestNew.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 dm.SendMessageFixedAsync(token); + await ctx.RestNew.CreateMessage(dm.Id, new MessageRequest { Content = token }); // If we're not already in a DM, reply with a reminder to check - // TODO: DMs - // if (!(ctx.Channel is DiscordDmChannel)) - // await ctx.Reply($"{Emojis.Success} Check your DMs!"); + if (ctx.ChannelNew.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) { // Can't check for permission errors beforehand, so have to handle here :/ - // TODO: DMs - // if (!(ctx.Channel is DiscordDmChannel)) - // await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); + if (ctx.ChannelNew.Type != Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); } } } From 9079f1c59c13c411f08b53f316937ba5815428ed Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 25 Dec 2020 13:58:45 +0100 Subject: [PATCH 08/26] Port the DM stuff --- Myriad/Extensions/CacheExtensions.cs | 11 +++++ Myriad/Gateway/Events/MessageUpdateEvent.cs | 5 +- .../JsonSerializerOptionsExtensions.cs | 1 + Myriad/Serialization/OptionalConverter.cs | 33 +++++++------ Myriad/Types/Message.cs | 7 ++- Myriad/Utils/Optional.cs | 8 +--- PluralKit.Bot/CommandSystem/Context.cs | 12 ----- .../ContextEntityArgumentsExt.cs | 1 - PluralKit.Bot/Commands/ImportExport.cs | 9 ++-- PluralKit.Bot/Commands/Member.cs | 2 +- PluralKit.Bot/Commands/Misc.cs | 2 +- PluralKit.Bot/Commands/Random.cs | 4 +- PluralKit.Bot/Handlers/MessageDeleted.cs | 1 - PluralKit.Bot/Handlers/ReactionAdded.cs | 29 +++++++++--- PluralKit.Bot/Services/EmbedService.cs | 47 +++++++++---------- 15 files changed, 95 insertions(+), 77 deletions(-) diff --git a/Myriad/Extensions/CacheExtensions.cs b/Myriad/Extensions/CacheExtensions.cs index 5686606e..d331e9e5 100644 --- a/Myriad/Extensions/CacheExtensions.cs +++ b/Myriad/Extensions/CacheExtensions.cs @@ -55,6 +55,17 @@ namespace Myriad.Extensions return restUser; } + public static async ValueTask GetOrFetchChannel(this IDiscordCache cache, DiscordApiClient rest, ulong channelId) + { + if (cache.TryGetChannel(channelId, out var 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 (cache.TryGetDmChannel(recipientId, out var cacheChannel)) diff --git a/Myriad/Gateway/Events/MessageUpdateEvent.cs b/Myriad/Gateway/Events/MessageUpdateEvent.cs index 9e77d076..63b34c1d 100644 --- a/Myriad/Gateway/Events/MessageUpdateEvent.cs +++ b/Myriad/Gateway/Events/MessageUpdateEvent.cs @@ -1,7 +1,10 @@ -namespace Myriad.Gateway +using Myriad.Utils; + +namespace Myriad.Gateway { public record MessageUpdateEvent(ulong Id, ulong ChannelId): IGatewayEvent { + public Optional Content { get; init; } // TODO: lots of partials } } \ No newline at end of file diff --git a/Myriad/Serialization/JsonSerializerOptionsExtensions.cs b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs index b72bec2e..5f45bba0 100644 --- a/Myriad/Serialization/JsonSerializerOptionsExtensions.cs +++ b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs @@ -13,6 +13,7 @@ namespace Myriad.Serialization opts.Converters.Add(new PermissionSetJsonConverter()); opts.Converters.Add(new ShardInfoJsonConverter()); + opts.Converters.Add(new OptionalConverterFactory()); return opts; } diff --git a/Myriad/Serialization/OptionalConverter.cs b/Myriad/Serialization/OptionalConverter.cs index c45d1caa..af7149f3 100644 --- a/Myriad/Serialization/OptionalConverter.cs +++ b/Myriad/Serialization/OptionalConverter.cs @@ -7,28 +7,33 @@ using Myriad.Utils; namespace Myriad.Serialization { - public class OptionalConverter: JsonConverter + public class OptionalConverterFactory: JsonConverterFactory { - public override IOptional? Read(ref Utf8JsonReader reader, 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!); + } + + public override void Write(Utf8JsonWriter writer, Optional value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.HasValue ? value.GetValue() : default, typeof(T), options); + } + } + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { var innerType = typeToConvert.GetGenericArguments()[0]; - var inner = JsonSerializer.Deserialize(ref reader, innerType, options); - - // TODO: rewrite to JsonConverterFactory to cut down on reflection - return (IOptional?) Activator.CreateInstance( - typeof(Optional<>).MakeGenericType(innerType), + return (JsonConverter?) Activator.CreateInstance( + typeof(Inner<>).MakeGenericType(innerType), BindingFlags.Instance | BindingFlags.Public, null, - new[] {inner}, + null, null); } - public override void Write(Utf8JsonWriter writer, IOptional value, JsonSerializerOptions options) - { - var innerType = value.GetType().GetGenericArguments()[0]; - JsonSerializer.Serialize(writer, value.GetValue(), innerType, options); - } - public override bool CanConvert(Type typeToConvert) { if (!typeToConvert.IsGenericType) diff --git a/Myriad/Types/Message.cs b/Myriad/Types/Message.cs index c74f67bf..977e1b9d 100644 --- a/Myriad/Types/Message.cs +++ b/Myriad/Types/Message.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Net.Mail; +using System.Text.Json.Serialization; + +using Myriad.Utils; namespace Myriad.Types { @@ -59,8 +62,8 @@ namespace Myriad.Types public Reference? MessageReference { get; set; } public MessageFlags Flags { get; init; } - // todo: null vs. absence - public Message? ReferencedMessage { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Optional ReferencedMessage { get; init; } public record Reference(ulong? GuildId, ulong? ChannelId, ulong? MessageId); diff --git a/Myriad/Utils/Optional.cs b/Myriad/Utils/Optional.cs index 7b1e4139..881a53ce 100644 --- a/Myriad/Utils/Optional.cs +++ b/Myriad/Utils/Optional.cs @@ -1,16 +1,10 @@ -using System.Text.Json.Serialization; - -using Myriad.Serialization; - -namespace Myriad.Utils +namespace Myriad.Utils { public interface IOptional { - bool HasValue { get; } object? GetValue(); } - [JsonConverter(typeof(OptionalConverter))] public readonly struct Optional: IOptional { public Optional(T value) diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index 037d8726..1ed55bb0 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -101,18 +101,6 @@ namespace PluralKit.Bot internal IDatabase Database => _db; internal ModelRepository Repository => _repo; - public Task Reply(string text, DiscordEmbed embed, - IEnumerable? mentions = null) - { - throw new NotImplementedException(); - } - - public Task Reply(DiscordEmbed embed, - IEnumerable? mentions = null) - { - throw new NotImplementedException(); - } - public async Task Reply(string text = null, Embed embed = null, AllowedMentions? mentions = null) { if (!BotPermissions.HasFlag(PermissionSet.SendMessages)) diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 136b75d2..44779677 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; -using Myriad.Cache; using Myriad.Extensions; using Myriad.Types; diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index 6d79f1bf..eb546e19 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading.Tasks; using Myriad.Rest.Exceptions; +using Myriad.Types; using Newtonsoft.Json; @@ -140,14 +141,14 @@ namespace PluralKit.Bot try { - var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); + var dm = await ctx.Rest.CreateDmAsync(ctx.AuthorNew.Id); + // TODO: send file var msg = await dm.SendFileAsync("system.json", stream, $"{Emojis.Success} Here you go!"); await dm.SendMessageAsync($"<{msg.Attachments[0].Url}>"); // If the original message wasn't posted in DMs, send a public reminder - // TODO: DMs - // if (!(ctx.Channel is DiscordDmChannel)) - // await ctx.Reply($"{Emojis.Success} Check your DMs!"); + if (ctx.ChannelNew.Type == Channel.ChannelType.Dm) + await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) { diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 59fce5cd..2a914090 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -73,7 +73,7 @@ namespace PluralKit.Bot public async Task ViewMember(Context ctx, PKMember target) { var system = await _db.Execute(c => _repo.GetSystem(c, target.System)); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system))); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.GuildNew, ctx.LookupContextFor(system))); } public async Task Soulscream(Context ctx, PKMember target) diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index 04b465ee..db4bc36e 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -228,7 +228,7 @@ namespace PluralKit.Bot { var message = await _db.Execute(c => _repo.GetMessage(c, messageId)); if (message == null) throw Errors.MessageNotFound(messageId); - await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(ctx.Shard, message)); + await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message)); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Random.cs b/PluralKit.Bot/Commands/Random.cs index 6c154cbc..51770206 100644 --- a/PluralKit.Bot/Commands/Random.cs +++ b/PluralKit.Bot/Commands/Random.cs @@ -38,7 +38,7 @@ namespace PluralKit.Bot throw new PKError("Your system has no members! Please create at least one member before using this command."); var randInt = randGen.Next(members.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.GuildNew, ctx.LookupContextFor(ctx.System))); } public async Task Group(Context ctx) @@ -73,7 +73,7 @@ namespace PluralKit.Bot 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))); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.GuildNew, ctx.LookupContextFor(ctx.System))); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageDeleted.cs b/PluralKit.Bot/Handlers/MessageDeleted.cs index 084ee861..9f1a607a 100644 --- a/PluralKit.Bot/Handlers/MessageDeleted.cs +++ b/PluralKit.Bot/Handlers/MessageDeleted.cs @@ -33,7 +33,6 @@ namespace PluralKit.Bot async Task Inner() { await Task.Delay(MessageDeleteDelay); - // TODO await _db.Execute(c => _repo.DeleteMessage(c, evt.Id)); } diff --git a/PluralKit.Bot/Handlers/ReactionAdded.cs b/PluralKit.Bot/Handlers/ReactionAdded.cs index 9c20c3b0..36412bce 100644 --- a/PluralKit.Bot/Handlers/ReactionAdded.cs +++ b/PluralKit.Bot/Handlers/ReactionAdded.cs @@ -7,6 +7,7 @@ using Myriad.Gateway; using Myriad.Rest; using Myriad.Rest.Exceptions; using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; using Myriad.Types; using PluralKit.Core; @@ -22,10 +23,11 @@ namespace PluralKit.Bot private readonly CommandMessageService _commandMessageService; private readonly ILogger _logger; private readonly IDiscordCache _cache; + private readonly EmbedService _embeds; private readonly Bot _bot; private readonly DiscordApiClient _rest; - public ReactionAdded(ILogger logger, IDatabase db, ModelRepository repo, CommandMessageService commandMessageService, IDiscordCache cache, Bot bot, DiscordApiClient rest) + public ReactionAdded(ILogger logger, IDatabase db, ModelRepository repo, CommandMessageService commandMessageService, IDiscordCache cache, Bot bot, DiscordApiClient rest, EmbedService embeds) { _db = db; _repo = repo; @@ -33,6 +35,7 @@ namespace PluralKit.Bot _cache = cache; _bot = bot; _rest = rest; + _embeds = embeds; _logger = logger.ForContext(); } @@ -151,13 +154,22 @@ namespace PluralKit.Bot private async ValueTask HandleQueryReaction(MessageReactionAddEvent evt, FullMessage msg) { + var guild = _cache.GetGuild(evt.GuildId!.Value); + // Try to DM the user info about the message // var member = await evt.Guild.GetMember(evt.User.Id); try { - // TODO: how to DM? - // await member.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, evt.Guild, LookupContext.ByNonOwner)); - // await member.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(shard, msg)); + 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) + }); } catch (UnauthorizedException) { } // No permissions to DM, can't check for this :( @@ -192,9 +204,12 @@ namespace PluralKit.Bot // If not, tell them in DMs (if we can) try { - // todo: how to dm - // await guildUser.SendMessageFixedAsync($"{Emojis.Error} {msg.Member.DisplayName()}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:"); - // await guildUser.SendMessageFixedAsync($"<@{msg.Message.Sender}>".AsCode()); + 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:" + }); + await _rest.CreateMessage(dm.Id, new MessageRequest {Content = $"<@{msg.Message.Sender}>".AsCode()}); } catch (UnauthorizedException) { } } diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index da8b7d10..5b33c70b 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -3,13 +3,13 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using DSharpPlus; using DSharpPlus.Entities; using Humanizer; using Myriad.Builders; using Myriad.Cache; +using Myriad.Extensions; using Myriad.Rest; using Myriad.Types; @@ -22,13 +22,11 @@ namespace PluralKit.Bot { { private readonly IDatabase _db; private readonly ModelRepository _repo; - private readonly DiscordShardedClient _client; private readonly IDiscordCache _cache; private readonly DiscordApiClient _rest; - public EmbedService(DiscordShardedClient client, IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest) + public EmbedService(IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest) { - _client = client; _db = db; _repo = repo; _cache = cache; @@ -39,14 +37,7 @@ namespace PluralKit.Bot { { async Task<(ulong Id, User? User)> Inner(ulong id) { - if (_cache.TryGetUser(id, out var cachedUser)) - return (id, cachedUser); - - var user = await _rest.GetUser(id); - if (user == null) - return (id, null); - // todo: move to "GetUserCached" helper - await _cache.SaveUser(user); + var user = await _cache.GetOrFetchUser(_rest, id); return (id, user); } @@ -108,7 +99,7 @@ namespace PluralKit.Bot { .Build(); } - public async Task CreateMemberEmbed(PKSystem system, PKMember member, DiscordGuild guild, LookupContext ctx) + public async Task CreateMemberEmbed(PKSystem system, PKMember member, Guild guild, LookupContext ctx) { // string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone)); @@ -233,26 +224,33 @@ namespace PluralKit.Bot { .Build(); } - public async Task CreateMessageInfoEmbed(DiscordClient client, FullMessage msg) + public async Task CreateMessageInfoEmbed(FullMessage msg) { + var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Channel); var ctx = LookupContext.ByNonOwner; - var channel = await _client.GetChannel(msg.Message.Channel); - var serverMsg = channel != null ? await channel.GetMessage(msg.Message.Mid) : null; + var serverMsg = channel != null ? await _rest.GetMessage(msg.Message.Channel, msg.Message.Mid) : null; // Need this whole dance to handle cases where: // - the user is deleted (userInfo == null) // - the bot's no longer in the server we're querying (channel == null) // - the member is no longer in the server we're querying (memberInfo == null) - DiscordMember memberInfo = null; - DiscordUser userInfo = null; - if (channel != null) memberInfo = await channel.Guild.GetMember(msg.Message.Sender); - if (memberInfo != null) userInfo = memberInfo; // Don't do an extra request if we already have this info from the member lookup - else userInfo = await client.GetUser(msg.Message.Sender); + // TODO: optimize ordering here a bit with new cache impl; and figure what happens if bot leaves server -> channel still cached -> hits this bit and 401s? + GuildMemberPartial memberInfo = null; + User userInfo = null; + if (channel != null) + { + var m = await _rest.GetGuildMember(channel.GuildId!.Value, msg.Message.Sender); + if (m != null) + // Don't do an extra request if we already have this info from the member lookup + userInfo = m.User; + memberInfo = m; + } + else userInfo = await _cache.GetOrFetchUser(_rest, msg.Message.Sender); // Calculate string displayed under "Sent by" string userStr; - if (memberInfo != null && memberInfo.Nickname != null) - userStr = $"**Username:** {memberInfo.NameAndMention()}\n**Nickname:** {memberInfo.Nickname}"; + if (memberInfo != null && memberInfo.Nick != null) + userStr = $"**Username:** {userInfo.NameAndMention()}\n**Nickname:** {memberInfo.Nick}"; else if (userInfo != null) userStr = userInfo.NameAndMention(); else userStr = $"*(deleted user {msg.Message.Sender})*"; @@ -270,7 +268,8 @@ namespace PluralKit.Bot { var roles = memberInfo?.Roles?.ToList(); if (roles != null && roles.Count > 0) { - var rolesString = string.Join(", ", roles.Select(role => role.Name)); + // TODO: what if role isn't in cache? figure out a fallback + var rolesString = string.Join(", ", roles.Select(id => _cache.GetRole(id).Name)); eb.Field(new($"Account roles ({roles.Count})", rolesString.Truncate(1024))); } From da9d84a197abc96979eb934076c513e415f8ab33 Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 15 Jan 2021 11:29:43 +0100 Subject: [PATCH 09/26] Get rid of more D#+ references --- Myriad/Extensions/PermissionExtensions.cs | 6 +++++ Myriad/Gateway/Shard.cs | 1 + Myriad/Rest/DiscordApiClient.cs | 4 ++-- PluralKit.Bot/CommandSystem/Context.cs | 27 ++++++---------------- PluralKit.Bot/Commands/Autoproxy.cs | 10 ++++---- PluralKit.Bot/Commands/CommandTree.cs | 2 +- PluralKit.Bot/Commands/Help.cs | 2 +- PluralKit.Bot/Commands/ImportExport.cs | 19 +++++++++------ PluralKit.Bot/Commands/Member.cs | 4 +--- PluralKit.Bot/Commands/MemberEdit.cs | 4 ++-- PluralKit.Bot/Commands/Misc.cs | 17 +++++--------- PluralKit.Bot/Commands/System.cs | 2 +- PluralKit.Bot/Commands/SystemLink.cs | 2 +- PluralKit.Bot/Services/EmbedService.cs | 12 ++++------ PluralKit.Bot/Services/ShardInfoService.cs | 4 +++- PluralKit.Bot/Utils/ContextUtils.cs | 4 ++-- PluralKit.Bot/Utils/DiscordUtils.cs | 14 +++++------ 17 files changed, 63 insertions(+), 71 deletions(-) diff --git a/Myriad/Extensions/PermissionExtensions.cs b/Myriad/Extensions/PermissionExtensions.cs index 60f4f52b..d78288a3 100644 --- a/Myriad/Extensions/PermissionExtensions.cs +++ b/Myriad/Extensions/PermissionExtensions.cs @@ -143,5 +143,11 @@ namespace Myriad.Extensions PermissionSet.SendTtsMessages | PermissionSet.AttachFiles | PermissionSet.EmbedLinks; + + public static string ToPermissionString(this PermissionSet perms) + { + // TODO: clean string + return perms.ToString(); + } } } \ No newline at end of file diff --git a/Myriad/Gateway/Shard.cs b/Myriad/Gateway/Shard.cs index cb00fb81..1ace1b91 100644 --- a/Myriad/Gateway/Shard.cs +++ b/Myriad/Gateway/Shard.cs @@ -27,6 +27,7 @@ namespace Myriad.Gateway private Task _worker; public ShardInfo? ShardInfo { get; private set; } + public int ShardId => ShardInfo?.ShardId ?? 0; public GatewaySettings Settings { get; } public ShardSessionInfo SessionInfo { get; private set; } public ShardState State { get; private set; } diff --git a/Myriad/Rest/DiscordApiClient.cs b/Myriad/Rest/DiscordApiClient.cs index 11bdddb9..257b3be6 100644 --- a/Myriad/Rest/DiscordApiClient.cs +++ b/Myriad/Rest/DiscordApiClient.cs @@ -46,8 +46,8 @@ namespace Myriad.Rest _client.Get($"/guilds/{guildId}/members/{userId}", ("GetGuildMember", guildId)); - public Task CreateMessage(ulong channelId, MessageRequest request) => - _client.Post($"/channels/{channelId}/messages", ("CreateMessage", channelId), request)!; + 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)!; diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index 1ed55bb0..d0135ced 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -1,39 +1,31 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; using App.Metrics; using Autofac; -using DSharpPlus; -using DSharpPlus.Entities; - using Myriad.Cache; using Myriad.Extensions; using Myriad.Gateway; +using Myriad.Rest; using Myriad.Rest.Types; using Myriad.Rest.Types.Requests; using Myriad.Types; using PluralKit.Core; -using DiscordApiClient = Myriad.Rest.DiscordApiClient; - namespace PluralKit.Bot { public class Context { private readonly ILifetimeScope _provider; - private readonly DiscordRestClient _rest; private readonly DiscordApiClient _newRest; - private readonly DiscordShardedClient _client; - private readonly DiscordClient _shard = null; + private readonly Cluster _cluster; private readonly Shard _shardNew; private readonly Guild? _guild; private readonly Channel _channel; - private readonly DiscordMessage _message = null; private readonly MessageCreateEvent _messageNew; private readonly Parameters _parameters; private readonly MessageContext _messageContext; @@ -52,8 +44,6 @@ namespace PluralKit.Bot public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, PKSystem senderSystem, MessageContext messageContext, PermissionSet botPermissions) { - _rest = provider.Resolve(); - _client = provider.Resolve(); _messageNew = message; _shardNew = shard; _guild = guild; @@ -66,8 +56,9 @@ namespace PluralKit.Bot _metrics = provider.Resolve(); _provider = provider; _commandMessageService = provider.Resolve(); - _parameters = new Parameters(message.Content.Substring(commandParseOffset)); + _parameters = new Parameters(message.Content?.Substring(commandParseOffset)); _newRest = provider.Resolve(); + _cluster = provider.Resolve(); _botPermissions = botPermissions; _userPermissions = _cache.PermissionsFor(message); @@ -75,23 +66,19 @@ namespace PluralKit.Bot public IDiscordCache Cache => _cache; - public DiscordUser Author => _message.Author; - public DiscordChannel Channel => _message.Channel; public Channel ChannelNew => _channel; public User AuthorNew => _messageNew.Author; public GuildMemberPartial MemberNew => _messageNew.Member; - public DiscordMessage Message => _message; + public Message MessageNew => _messageNew; - public DiscordGuild Guild => _message.Channel.Guild; public Guild GuildNew => _guild; - public DiscordClient Shard => _shard; - public DiscordShardedClient Client => _client; + public Shard ShardNew => _shardNew; + public Cluster Cluster => _cluster; public MessageContext MessageContext => _messageContext; public PermissionSet BotPermissions => _botPermissions; public PermissionSet UserPermissions => _userPermissions; - public DiscordRestClient Rest => _rest; public DiscordApiClient RestNew => _newRest; public PKSystem System => _senderSystem; diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index d7f51975..15abc6fc 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -129,7 +129,7 @@ namespace PluralKit.Bot } 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`.")); + eb.Field(new("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.AuthorNew.Id}>). To enable it, use `pk;autoproxy account enable`.")); return eb.Build(); } @@ -191,7 +191,7 @@ namespace PluralKit.Bot else { var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled"; - await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>."); + await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.AuthorNew.Id}>."); } } @@ -200,12 +200,12 @@ namespace PluralKit.Bot var statusString = allow ? "enabled" : "disabled"; if (ctx.MessageContext.AllowAutoproxy == allow) { - await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>."); + await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.AuthorNew.Id}>."); return; } var patch = new AccountPatch { AllowAutoproxy = allow }; - await _db.Execute(conn => _repo.UpdateAccount(conn, ctx.Author.Id, patch)); - await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); + await _db.Execute(conn => _repo.UpdateAccount(conn, ctx.AuthorNew.Id, patch)); + await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.AuthorNew.Id}>."); } private Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index a5868184..a8132c5e 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -524,7 +524,7 @@ namespace PluralKit.Bot { // Try to resolve the user ID to find the associated account, // so we can print their username. - var user = await ctx.Shard.GetUser(id); + var user = await ctx.RestNew.GetUser(id); if (user != null) return $"Account **{user.Username}#{user.Discriminator}** does not have a system registered."; else diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index dff1bf33..d4bd9e9f 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -20,7 +20,7 @@ namespace PluralKit.Bot .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((uint?) DiscordUtils.Blue.Value) + .Color(DiscordUtils.Blue) .Build()); } diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index eb546e19..fda3afe7 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -6,6 +6,8 @@ using System.Text; using System.Threading.Tasks; using Myriad.Rest.Exceptions; +using Myriad.Rest.Types; +using Myriad.Rest.Types.Requests; using Myriad.Types; using Newtonsoft.Json; @@ -32,7 +34,7 @@ namespace PluralKit.Bot public async Task Import(Context ctx) { - var url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url; + var url = ctx.RemainderOrNull() ?? ctx.MessageNew.Attachments.FirstOrDefault()?.Url; if (url == null) throw Errors.NoImportFilePassed; await ctx.BusyIndicator(async () => @@ -67,7 +69,7 @@ namespace PluralKit.Bot if (!data.Valid) throw Errors.InvalidImportFile; - if (data.LinkedAccounts != null && !data.LinkedAccounts.Contains(ctx.Author.Id)) + if (data.LinkedAccounts != null && !data.LinkedAccounts.Contains(ctx.AuthorNew.Id)) { var msg = $"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?"; if (!await ctx.PromptYesNo(msg)) throw Errors.ImportCancelled; @@ -75,7 +77,7 @@ namespace PluralKit.Bot // If passed system is null, it'll create a new one // (and that's okay!) - var result = await _dataFiles.ImportSystem(data, ctx.System, ctx.Author.Id); + var result = await _dataFiles.ImportSystem(data, ctx.System, ctx.AuthorNew.Id); if (!result.Success) await ctx.Reply($"{Emojis.Error} The provided system profile could not be imported. {result.Message}"); else if (ctx.System == null) @@ -141,13 +143,16 @@ namespace PluralKit.Bot try { - var dm = await ctx.Rest.CreateDmAsync(ctx.AuthorNew.Id); + var dm = await ctx.RestNew.CreateDm(ctx.AuthorNew.Id); // TODO: send file - var msg = await dm.SendFileAsync("system.json", stream, $"{Emojis.Success} Here you go!"); - await dm.SendMessageAsync($"<{msg.Attachments[0].Url}>"); + + var msg = await ctx.RestNew.CreateMessage(dm.Id, + new MessageRequest {Content = $"{Emojis.Success} Here you go!"}, + new[] {new MultipartFile("system.json", stream)}); + await ctx.RestNew.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.ChannelNew.Type == Channel.ChannelType.Dm) + if (ctx.ChannelNew.Type != Channel.ChannelType.Dm) await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 2a914090..20229368 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -5,8 +5,6 @@ using System.Web; using Dapper; -using DSharpPlus.Entities; - using Myriad.Builders; using Newtonsoft.Json.Linq; @@ -92,7 +90,7 @@ namespace PluralKit.Bot var scream = data["soulscream"]!.Value(); var eb = new EmbedBuilder() - .Color((uint?) DiscordColor.Red.Value) + .Color(DiscordUtils.Red) .Title(name) .Url($"https://onomancer.sibr.dev/reflect?name={encoded}") .Description($"*{scream}*"); diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 017441d0..6dc1f1c6 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -160,7 +160,7 @@ namespace PluralKit.Bot else await ctx.Reply(embed: new EmbedBuilder() .Title("Member color") - .Color((uint?) target.Color.ToDiscordColor()!.Value.Value) + .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`." : "")) @@ -178,7 +178,7 @@ namespace PluralKit.Bot await ctx.Reply(embed: new EmbedBuilder() .Title($"{Emojis.Success} Member color changed.") - .Color((uint?) color.ToDiscordColor()!.Value.Value) + .Color(color.ToDiscordColor()) .Thumbnail(new($"https://fakeimg.pl/256x256/{color}/?text=%20")) .Build()); } diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index db4bc36e..92ea99e4 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -6,8 +6,6 @@ using System.Threading.Tasks; using App.Metrics; -using DSharpPlus; - using Humanizer; using NodaTime; @@ -22,8 +20,6 @@ using Myriad.Rest; using Myriad.Rest.Types.Requests; using Myriad.Types; -using Permissions = DSharpPlus.Permissions; - namespace PluralKit.Bot { public class Misc { @@ -89,10 +85,10 @@ namespace PluralKit.Bot { var totalMessages = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.MessageCount.Name)?.Value ?? 0; // TODO: shard stuff - var shardId = ctx.Shard.ShardId; - var shardTotal = ctx.Client.ShardClients.Count; + var shardId = ctx.ShardNew.ShardInfo?.ShardId ?? -1; + var shardTotal = ctx.Cluster.Shards.Count; var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count(); - var shardInfo = _shards.GetShardInfo(ctx.Shard); + var shardInfo = _shards.GetShardInfo(ctx.ShardNew); var process = Process.GetCurrentProcess(); var memoryUsage = process.WorkingSet64; @@ -188,7 +184,7 @@ namespace PluralKit.Bot { if (permissionsMissing.Count == 0) { - eb.Description($"No errors found, all channels proxyable :)").Color((uint?) DiscordUtils.Green.Value); + eb.Description($"No errors found, all channels proxyable :)").Color(DiscordUtils.Green); } else { @@ -196,14 +192,13 @@ namespace PluralKit.Bot { { // Each missing permission field can have multiple missing channels // so we extract them all and generate a comma-separated list - // TODO: port ToPermissionString? - var missingPermissionNames = ((Permissions)missingPermissionField).ToPermissionString(); + 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((uint?) DiscordUtils.Red.Value); + eb.Color(DiscordUtils.Red); } } diff --git a/PluralKit.Bot/Commands/System.cs b/PluralKit.Bot/Commands/System.cs index d531196d..b1575c95 100644 --- a/PluralKit.Bot/Commands/System.cs +++ b/PluralKit.Bot/Commands/System.cs @@ -34,7 +34,7 @@ namespace PluralKit.Bot var system = _db.Execute(async c => { var system = await _repo.CreateSystem(c, systemName); - await _repo.AddAccount(c, system.Id, ctx.Author.Id); + await _repo.AddAccount(c, system.Id, ctx.AuthorNew.Id); return system; }); diff --git a/PluralKit.Bot/Commands/SystemLink.cs b/PluralKit.Bot/Commands/SystemLink.cs index 0ebc0d83..24042094 100644 --- a/PluralKit.Bot/Commands/SystemLink.cs +++ b/PluralKit.Bot/Commands/SystemLink.cs @@ -49,7 +49,7 @@ namespace PluralKit.Bot ulong id; if (!ctx.HasNext()) - id = ctx.Author.Id; + id = ctx.AuthorNew.Id; else if (!ctx.MatchUserRaw(out id)) throw new PKSyntaxError("You must pass an account to link with (either ID or @mention)."); diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 5b33c70b..1efc3506 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using DSharpPlus.Entities; - using Humanizer; using Myriad.Builders; @@ -58,7 +56,7 @@ namespace PluralKit.Bot { .Title(system.Name) .Thumbnail(new(system.AvatarUrl)) .Footer(new($"System ID: {system.Hid} | Created on {system.Created.FormatZoned(system)}")) - .Color((uint) DiscordUtils.Gray.Value); + .Color(DiscordUtils.Gray); var latestSwitch = await _repo.GetLatestSwitch(conn, system.Id); if (latestSwitch != null && system.FrontPrivacy.CanAccess(ctx)) @@ -107,7 +105,7 @@ namespace PluralKit.Bot { var name = member.NameFor(ctx); if (system.Name != null) name = $"{name} ({system.Name})"; - DiscordColor color; + uint color; try { color = member.Color?.ToDiscordColor() ?? DiscordUtils.Gray; @@ -135,7 +133,7 @@ namespace PluralKit.Bot { // TODO: add URL of website when that's up .Author(new(name, IconUrl: DiscordUtils.WorkaroundForUrlBug(avatar))) // .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray) - .Color((uint?) color.Value) + .Color(color) .Footer(new( $"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(system)}" : "")}")); @@ -218,7 +216,7 @@ namespace PluralKit.Bot { var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id).ToListAsync().AsTask()); var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; return new EmbedBuilder() - .Color((uint?) (members.FirstOrDefault()?.Color?.ToDiscordColor()?.Value ?? DiscordUtils.Gray.Value)) + .Color(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordUtils.Gray) .Field(new($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.NameFor(ctx))) : "*(no fronter)*")) .Field(new("Since", $"{sw.Timestamp.FormatZoned(zone)} ({timeSinceSwitch.FormatDuration()} ago)")) .Build(); @@ -280,7 +278,7 @@ namespace PluralKit.Bot { { var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart; var eb = new EmbedBuilder() - .Color((uint?) DiscordUtils.Gray.Value) + .Color(DiscordUtils.Gray) .Footer(new($"Since {breakdown.RangeStart.FormatZoned(tz)} ({actualPeriod.FormatDuration()} ago)")); var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others" diff --git a/PluralKit.Bot/Services/ShardInfoService.cs b/PluralKit.Bot/Services/ShardInfoService.cs index 7bbbe2a1..a89a0bd2 100644 --- a/PluralKit.Bot/Services/ShardInfoService.cs +++ b/PluralKit.Bot/Services/ShardInfoService.cs @@ -7,6 +7,8 @@ using App.Metrics; using DSharpPlus; using DSharpPlus.EventArgs; +using Myriad.Gateway; + using NodaTime; using NodaTime.Extensions; @@ -144,7 +146,7 @@ namespace PluralKit.Bot return Task.CompletedTask; } - public ShardInfo GetShardInfo(DiscordClient shard) => _shardInfo[shard.ShardId]; + public ShardInfo GetShardInfo(Shard shard) => _shardInfo[shard.ShardId]; public ICollection Shards => _shardInfo.Values; } diff --git a/PluralKit.Bot/Utils/ContextUtils.cs b/PluralKit.Bot/Utils/ContextUtils.cs index c245d549..58b281c5 100644 --- a/PluralKit.Bot/Utils/ContextUtils.cs +++ b/PluralKit.Bot/Utils/ContextUtils.cs @@ -88,7 +88,7 @@ namespace PluralKit.Bot { public static async Task ConfirmWithReply(this Context ctx, string expectedReply) { bool Predicate(MessageCreateEvent e) => - e.Author.Id == ctx.AuthorNew.Id && e.ChannelId == ctx.Channel.Id; + e.Author.Id == ctx.AuthorNew.Id && e.ChannelId == ctx.ChannelNew.Id; var msg = await ctx.Services.Resolve>() .WaitFor(Predicate, Duration.FromMinutes(1)); @@ -217,7 +217,7 @@ namespace PluralKit.Bot { if (idx < items.Count) return items[idx]; } - var __ = ctx.RestNew.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, ctx.Author.Id); + var __ = ctx.RestNew.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, ctx.AuthorNew.Id); await ctx.RestNew.EditMessage(msg.ChannelId, msg.Id, new() { diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index 281324e8..1581f689 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -28,10 +28,10 @@ namespace PluralKit.Bot { public static class DiscordUtils { - public static DiscordColor Blue = new DiscordColor(0x1f99d8); - public static DiscordColor Green = new DiscordColor(0x00cc78); - public static DiscordColor Red = new DiscordColor(0xef4b3d); - public static DiscordColor Gray = new DiscordColor(0x979c9f); + public const uint Blue = 0x1f99d8; + public const uint Green = 0x00cc78; + public const uint Red = 0xef4b3d; + public const uint Gray = 0x979c9f; public static Permissions DM_PERMISSIONS = (Permissions) 0b00000_1000110_1011100110000_000000; @@ -154,10 +154,10 @@ namespace PluralKit.Bot return cache != null && cache.TryGetValue(id, out user); } - public static DiscordColor? ToDiscordColor(this string color) + public static uint? ToDiscordColor(this string color) { - if (int.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) - return new DiscordColor(colorInt); + if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) + return colorInt; throw new ArgumentException($"Invalid color string '{color}'."); } From d56e878c28d9ed1c690e4488d69b6c119de9a814 Mon Sep 17 00:00:00 2001 From: Ske Date: Sat, 30 Jan 2021 01:07:43 +0100 Subject: [PATCH 10/26] Converted shard and logclean service --- Myriad/Extensions/MessageExtensions.cs | 4 +- Myriad/Extensions/SnowflakeExtensions.cs | 20 +++ Myriad/Gateway/Cluster.cs | 6 +- Myriad/Gateway/Shard.cs | 84 +++++---- Myriad/Gateway/ShardConnection.cs | 17 +- Myriad/Rest/BaseRestClient.cs | 2 +- Myriad/Rest/Ratelimit/Bucket.cs | 29 ++- .../JsonSerializerOptionsExtensions.cs | 2 +- PluralKit.Bot/Bot.cs | 2 + PluralKit.Bot/Services/LoggerCleanService.cs | 165 +++++++++++------- PluralKit.Bot/Services/ShardInfoService.cs | 86 +++++---- 11 files changed, 264 insertions(+), 153 deletions(-) create mode 100644 Myriad/Extensions/SnowflakeExtensions.cs diff --git a/Myriad/Extensions/MessageExtensions.cs b/Myriad/Extensions/MessageExtensions.cs index 60adb532..56664154 100644 --- a/Myriad/Extensions/MessageExtensions.cs +++ b/Myriad/Extensions/MessageExtensions.cs @@ -1,4 +1,6 @@ -using Myriad.Gateway; +using System; + +using Myriad.Gateway; using Myriad.Types; namespace Myriad.Extensions diff --git a/Myriad/Extensions/SnowflakeExtensions.cs b/Myriad/Extensions/SnowflakeExtensions.cs new file mode 100644 index 00000000..71446138 --- /dev/null +++ b/Myriad/Extensions/SnowflakeExtensions.cs @@ -0,0 +1,20 @@ +using System; + +using Myriad.Types; + +namespace Myriad.Extensions +{ + public static class SnowflakeExtensions + { + public static readonly DateTimeOffset DiscordEpoch = new(2015, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static DateTimeOffset SnowflakeToTimestamp(ulong snowflake) => + DiscordEpoch + TimeSpan.FromMilliseconds(snowflake >> 22); + + public static DateTimeOffset Timestamp(this Message msg) => SnowflakeToTimestamp(msg.Id); + public static DateTimeOffset Timestamp(this Channel channel) => SnowflakeToTimestamp(channel.Id); + public static DateTimeOffset Timestamp(this Guild guild) => SnowflakeToTimestamp(guild.Id); + public static DateTimeOffset Timestamp(this Webhook webhook) => SnowflakeToTimestamp(webhook.Id); + public static DateTimeOffset Timestamp(this User user) => SnowflakeToTimestamp(user.Id); + } +} \ No newline at end of file diff --git a/Myriad/Gateway/Cluster.cs b/Myriad/Gateway/Cluster.cs index 63e8a2cc..220eadc1 100644 --- a/Myriad/Gateway/Cluster.cs +++ b/Myriad/Gateway/Cluster.cs @@ -23,6 +23,7 @@ namespace Myriad.Gateway } public Func? EventReceived { get; set; } + public event Action? ShardCreated; public IReadOnlyDictionary Shards => _shards; public ClusterSessionState SessionState => GetClusterState(); @@ -35,7 +36,8 @@ namespace Myriad.Gateway foreach (var (id, shard) in _shards) shards.Add(new ClusterSessionState.ShardState { - Shard = shard.ShardInfo ?? new ShardInfo(id, _shards.Count), Session = shard.SessionInfo + Shard = shard.ShardInfo, + Session = shard.SessionInfo }); return new ClusterSessionState {Shards = shards}; @@ -78,6 +80,8 @@ namespace Myriad.Gateway var shard = new Shard(_logger, new Uri(url), _gatewaySettings, shardInfo, session); shard.OnEventReceived += evt => OnShardEventReceived(shard, evt); _shards[shardInfo.ShardId] = shard; + + ShardCreated?.Invoke(shard); } private async Task OnShardEventReceived(Shard shard, IGatewayEvent evt) diff --git a/Myriad/Gateway/Shard.cs b/Myriad/Gateway/Shard.cs index 1ace1b91..25cbba81 100644 --- a/Myriad/Gateway/Shard.cs +++ b/Myriad/Gateway/Shard.cs @@ -12,10 +12,10 @@ namespace Myriad.Gateway { public class Shard: IAsyncDisposable { - private const string LibraryName = "Newcord Test"; + private const string LibraryName = "Myriad (for PluralKit)"; private readonly JsonSerializerOptions _jsonSerializerOptions = - new JsonSerializerOptions().ConfigureForNewcord(); + new JsonSerializerOptions().ConfigureForMyriad(); private readonly ILogger _logger; private readonly Uri _uri; @@ -26,8 +26,8 @@ namespace Myriad.Gateway private DateTimeOffset? _lastHeartbeatSent; private Task _worker; - public ShardInfo? ShardInfo { get; private set; } - public int ShardId => ShardInfo?.ShardId ?? 0; + public ShardInfo ShardInfo { get; private set; } + public int ShardId => ShardInfo.ShardId; public GatewaySettings Settings { get; } public ShardSessionInfo SessionInfo { get; private set; } public ShardState State { get; private set; } @@ -36,11 +36,16 @@ namespace Myriad.Gateway public ApplicationPartial? Application { get; private set; } public Func? OnEventReceived { get; set; } + public event Action? HeartbeatReceived; + public event Action? SocketOpened; + public event Action? Resumed; + public event Action? Ready; + public event Action? SocketClosed; - public Shard(ILogger logger, Uri uri, GatewaySettings settings, ShardInfo? info = null, + public Shard(ILogger logger, Uri uri, GatewaySettings settings, ShardInfo info, ShardSessionInfo? sessionInfo = null) { - _logger = logger; + _logger = logger.ForContext(); _uri = uri; Settings = settings; @@ -71,23 +76,23 @@ namespace Myriad.Gateway while (true) try { - _logger.Information("Connecting..."); + _logger.Information("Shard {ShardId}: Connecting...", ShardId); State = ShardState.Connecting; await Connect(); - _logger.Information("Connected. Entering main loop..."); + _logger.Information("Shard {ShardId}: Connected. Entering main loop...", ShardId); // Tick returns false if we need to stop and reconnect while (await Tick(_conn!)) await Task.Delay(TimeSpan.FromMilliseconds(1000)); - _logger.Information("Connection closed, reconnecting..."); + _logger.Information("Shard {ShardId}: Connection closed, reconnecting...", ShardId); State = ShardState.Closed; } catch (Exception e) { - _logger.Error(e, "Error in shard state handler"); + _logger.Error(e, "Shard {ShardId}: Error in shard state handler", ShardId); } } @@ -116,8 +121,8 @@ namespace Myriad.Gateway if (!_hasReceivedAck) { _logger.Warning( - "Did not receive heartbeat Ack from gateway within interval ({HeartbeatInterval})", - _currentHeartbeatInterval); + "Shard {ShardId}: Did not receive heartbeat Ack from gateway within interval ({HeartbeatInterval})", + ShardId, _currentHeartbeatInterval); State = ShardState.Closing; await conn.Disconnect(WebSocketCloseStatus.ProtocolError, "Did not receive ACK in time"); return false; @@ -131,7 +136,8 @@ namespace Myriad.Gateway private async Task SendHeartbeat(ShardConnection conn) { - _logger.Debug("Sending heartbeat"); + _logger.Debug("Shard {ShardId}: Sending heartbeat with seq.no. {LastSequence}", + ShardId, SessionInfo.LastSequence); await conn.Send(new GatewayPacket {Opcode = GatewayOpcode.Heartbeat, Payload = SessionInfo.LastSequence}); _lastHeartbeatSent = DateTimeOffset.UtcNow; @@ -144,7 +150,12 @@ namespace Myriad.Gateway _currentHeartbeatInterval = null; - _conn = new ShardConnection(_uri, _logger, _jsonSerializerOptions) {OnReceive = OnReceive}; + _conn = new ShardConnection(_uri, _logger, _jsonSerializerOptions) + { + OnReceive = OnReceive, + OnOpen = () => SocketOpened?.Invoke(), + OnClose = (closeStatus, message) => SocketClosed?.Invoke(closeStatus, message) + }; } private async Task OnReceive(GatewayPacket packet) @@ -158,21 +169,23 @@ namespace Myriad.Gateway } case GatewayOpcode.Heartbeat: { - _logger.Debug("Received heartbeat request from shard, sending Ack"); + _logger.Debug("Shard {ShardId}: Received heartbeat request from shard, sending Ack", ShardId); await _conn!.Send(new GatewayPacket {Opcode = GatewayOpcode.HeartbeatAck}); break; } case GatewayOpcode.HeartbeatAck: { Latency = DateTimeOffset.UtcNow - _lastHeartbeatSent; - _logger.Debug("Received heartbeat Ack (latency {Latency})", Latency); + _logger.Debug("Shard {ShardId}: Received heartbeat Ack with latency {Latency}", ShardId, Latency); + if (Latency != null) + HeartbeatReceived?.Invoke(Latency!.Value); _hasReceivedAck = true; break; } case GatewayOpcode.Reconnect: { - _logger.Information("Received Reconnect, closing and reconnecting"); + _logger.Information("Shard {ShardId}: Received Reconnect, closing and reconnecting", ShardId); await _conn!.Disconnect(WebSocketCloseStatus.Empty, null); break; } @@ -187,8 +200,8 @@ namespace Myriad.Gateway var delay = TimeSpan.FromMilliseconds(new Random().Next(1000, 5000)); _logger.Information( - "Received Invalid Session (can resume? {CanResume}), reconnecting after {ReconnectDelay}", - canResume, delay); + "Shard {ShardId}: Received Invalid Session (can resume? {CanResume}), reconnecting after {ReconnectDelay}", + ShardId, canResume, delay); await _conn!.Disconnect(WebSocketCloseStatus.Empty, null); // Will reconnect after exiting this "loop" @@ -205,15 +218,16 @@ namespace Myriad.Gateway if (State == ShardState.Connecting) await HandleReady(rdy); else - _logger.Warning("Received Ready event in unexpected state {ShardState}, ignoring?", State); + _logger.Warning("Shard {ShardId}: Received Ready event in unexpected state {ShardState}, ignoring?", + ShardId, State); } else if (evt is ResumedEvent) { if (State == ShardState.Connecting) await HandleResumed(); else - _logger.Warning("Received Resumed event in unexpected state {ShardState}, ignoring?", - State); + _logger.Warning("Shard {ShardId}: Received Resumed event in unexpected state {ShardState}, ignoring?", + ShardId, State); } await HandleEvent(evt); @@ -221,7 +235,7 @@ namespace Myriad.Gateway } default: { - _logger.Debug("Received unknown gateway opcode {Opcode}", packet.Opcode); + _logger.Debug("Shard {ShardId}: Received unknown gateway opcode {Opcode}", ShardId, packet.Opcode); break; } } @@ -238,44 +252,47 @@ namespace Myriad.Gateway { if (!IGatewayEvent.EventTypes.TryGetValue(eventType, out var clrType)) { - _logger.Information("Received unknown event type {EventType}", eventType); + _logger.Information("Shard {ShardId}: Received unknown event type {EventType}", ShardId, eventType); return null; } try { - _logger.Verbose("Deserializing {EventType} to {ClrType}", eventType, clrType); + _logger.Verbose("Shard {ShardId}: Deserializing {EventType} to {ClrType}", ShardId, eventType, clrType); return JsonSerializer.Deserialize(data.GetRawText(), clrType, _jsonSerializerOptions) as IGatewayEvent; } catch (JsonException e) { - _logger.Error(e, "Error deserializing event {EventType} to {ClrType}", eventType, clrType); + _logger.Error(e, "Shard {ShardId}: Error deserializing event {EventType} to {ClrType}", ShardId, eventType, clrType); return null; } } private Task HandleReady(ReadyEvent ready) { - ShardInfo = ready.Shard; + // TODO: when is ready.Shard ever null? + ShardInfo = ready.Shard ?? new ShardInfo(0, 0); SessionInfo = SessionInfo with { Session = ready.SessionId }; User = ready.User; Application = ready.Application; State = ShardState.Open; - + + Ready?.Invoke(); return Task.CompletedTask; } private Task HandleResumed() { State = ShardState.Open; + Resumed?.Invoke(); return Task.CompletedTask; } private async Task HandleHello(JsonElement json) { var hello = JsonSerializer.Deserialize(json.GetRawText(), _jsonSerializerOptions)!; - _logger.Debug("Received Hello with interval {Interval} ms", hello.HeartbeatInterval); + _logger.Debug("Shard {ShardId}: Received Hello with interval {Interval} ms", ShardId, hello.HeartbeatInterval); _currentHeartbeatInterval = TimeSpan.FromMilliseconds(hello.HeartbeatInterval); await SendHeartbeat(_conn!); @@ -293,7 +310,7 @@ namespace Myriad.Gateway private async Task SendIdentify() { - _logger.Information("Sending gateway Identify for shard {@ShardInfo}", SessionInfo); + _logger.Information("Shard {ShardId}: Sending gateway Identify for shard {@ShardInfo}", ShardId, ShardInfo); await _conn!.Send(new GatewayPacket { Opcode = GatewayOpcode.Identify, @@ -312,11 +329,12 @@ namespace Myriad.Gateway private async Task SendResume(string session, int lastSequence) { - _logger.Information("Sending gateway Resume for session {@SessionInfo}", ShardInfo, - SessionInfo); + _logger.Information("Shard {ShardId}: Sending gateway Resume for session {@SessionInfo}", + ShardId, SessionInfo); await _conn!.Send(new GatewayPacket { - Opcode = GatewayOpcode.Resume, Payload = new GatewayResume(Settings.Token, session, lastSequence) + Opcode = GatewayOpcode.Resume, + Payload = new GatewayResume(Settings.Token, session, lastSequence) }); } diff --git a/Myriad/Gateway/ShardConnection.cs b/Myriad/Gateway/ShardConnection.cs index 77453de2..886e0664 100644 --- a/Myriad/Gateway/ShardConnection.cs +++ b/Myriad/Gateway/ShardConnection.cs @@ -29,9 +29,12 @@ namespace Myriad.Gateway } public Func? OnReceive { get; set; } + public Action? OnOpen { get; set; } + + public Action? OnClose { get; set; } public WebSocketState State => _client.State; - + public async ValueTask DisposeAsync() { _cts.Cancel(); @@ -50,8 +53,14 @@ namespace Myriad.Gateway }.Uri; _logger.Debug("Connecting to gateway WebSocket at {GatewayUrl}", realUrl); await _client.ConnectAsync(realUrl, default); - + _logger.Debug("Gateway connection opened"); + + OnOpen?.Invoke(); + + // Main worker loop, spins until we manually disconnect (which hits the cancellation token) + // or the server disconnects us (which sets state to closed) while (!_cts.IsCancellationRequested && _client.State == WebSocketState.Open) + { try { await HandleReceive(); @@ -60,6 +69,9 @@ namespace Myriad.Gateway { _logger.Error(e, "Error in WebSocket receive worker"); } + } + + OnClose?.Invoke(_client.CloseStatus ?? default, _client.CloseStatusDescription); } private async Task HandleReceive() @@ -92,6 +104,7 @@ namespace Myriad.Gateway private async Task ReadData(MemoryStream stream) { + // TODO: does this throw if we disconnect mid-read? using var buf = MemoryPool.Shared.Rent(); ValueWebSocketReceiveResult result; do diff --git a/Myriad/Rest/BaseRestClient.cs b/Myriad/Rest/BaseRestClient.cs index ad35cc0a..40a85c68 100644 --- a/Myriad/Rest/BaseRestClient.cs +++ b/Myriad/Rest/BaseRestClient.cs @@ -40,7 +40,7 @@ namespace Myriad.Rest Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent); Client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", token); - _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForNewcord(); + _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad(); _ratelimiter = new Ratelimiter(logger); var discordPolicy = new DiscordRateLimitPolicy(_ratelimiter); diff --git a/Myriad/Rest/Ratelimit/Bucket.cs b/Myriad/Rest/Ratelimit/Bucket.cs index e9d0eb5f..7f49ec33 100644 --- a/Myriad/Rest/Ratelimit/Bucket.cs +++ b/Myriad/Rest/Ratelimit/Bucket.cs @@ -15,9 +15,9 @@ namespace Myriad.Rest.Ratelimit private readonly ILogger _logger; private readonly SemaphoreSlim _semaphore = new(1, 1); - private DateTimeOffset _nextReset; + private DateTimeOffset? _nextReset; private bool _resetTimeValid; - private bool _hasReceivedRemaining; + private bool _hasReceivedHeaders; public Bucket(ILogger logger, string key, ulong major, int limit) { @@ -54,6 +54,7 @@ namespace Myriad.Rest.Ratelimit "{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit} left], allowing through", Key, Major, Remaining, Limit); Remaining--; + return true; } @@ -78,21 +79,25 @@ namespace Myriad.Rest.Ratelimit var headerNextReset = DateTimeOffset.UtcNow + headers.ResetAfter.Value; // todo: server time if (headerNextReset > _nextReset) { - _logger.Debug("{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server (after: {NextResetAfter}, new remaining: {Remaining})", - Key, Major, headerNextReset, headers.ResetAfter.Value, headers.Remaining); + _logger.Debug("{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) + if (headers.Limit != null) Limit = headers.Limit.Value; - if (headers.Remaining != null && !_hasReceivedRemaining) + if (headers.Remaining != null && !_hasReceivedHeaders) { - _hasReceivedRemaining = true; - Remaining = headers.Remaining.Value; + 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 @@ -106,6 +111,12 @@ namespace Myriad.Rest.Ratelimit 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; @@ -147,7 +158,7 @@ namespace Myriad.Rest.Ratelimit if (!_resetTimeValid) return FallbackDelay; - var delay = _nextReset - now; + var delay = (_nextReset ?? now) - now; // If we have a really small (or negative) value, return a fallback delay too if (delay < Epsilon) diff --git a/Myriad/Serialization/JsonSerializerOptionsExtensions.cs b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs index 5f45bba0..50b1192f 100644 --- a/Myriad/Serialization/JsonSerializerOptionsExtensions.cs +++ b/Myriad/Serialization/JsonSerializerOptionsExtensions.cs @@ -5,7 +5,7 @@ namespace Myriad.Serialization { public static class JsonSerializerOptionsExtensions { - public static JsonSerializerOptions ConfigureForNewcord(this JsonSerializerOptions opts) + public static JsonSerializerOptions ConfigureForMyriad(this JsonSerializerOptions opts) { opts.PropertyNamingPolicy = new JsonSnakeCaseNamingPolicy(); opts.NumberHandling = JsonNumberHandling.AllowReadingFromString; diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index b73a6db3..17c8bfad 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -173,6 +173,8 @@ namespace PluralKit.Bot async Task HandleEventInner() { + await Task.Yield(); + using var _ = LogContext.PushProperty("EventId", Guid.NewGuid()); _logger .ForContext("Elastic", "yes?") diff --git a/PluralKit.Bot/Services/LoggerCleanService.cs b/PluralKit.Bot/Services/LoggerCleanService.cs index b0b56b9e..9b97104d 100644 --- a/PluralKit.Bot/Services/LoggerCleanService.cs +++ b/PluralKit.Bot/Services/LoggerCleanService.cs @@ -4,31 +4,40 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.Entities; - +using Myriad.Cache; +using Myriad.Extensions; +using Myriad.Rest; +using Myriad.Rest.Exceptions; using Myriad.Types; +using Dapper; + +using NodaTime; +using NodaTime.Extensions; +using NodaTime.Text; + using PluralKit.Core; +using Serilog; + namespace PluralKit.Bot { public class LoggerCleanService { - private static readonly Regex _basicRegex = new Regex("(\\d{17,19})"); - private static readonly Regex _dynoRegex = new Regex("Message ID: (\\d{17,19})"); - private static readonly Regex _carlRegex = new Regex("ID: (\\d{17,19})"); - private static readonly Regex _circleRegex = new Regex("\\(`(\\d{17,19})`\\)"); - private static readonly Regex _loggerARegex = new Regex("Message = (\\d{17,19})"); - private static readonly Regex _loggerBRegex = new Regex("MessageID:(\\d{17,19})"); - private static readonly Regex _auttajaRegex = new Regex("Message (\\d{17,19}) deleted"); - private static readonly Regex _mantaroRegex = new Regex("Message \\(?ID:? (\\d{17,19})\\)? created by .* in channel .* was deleted\\."); - private static readonly Regex _pancakeRegex = new Regex("Message from <@(\\d{17,19})> deleted in"); - private static readonly Regex _unbelievaboatRegex = new Regex("Message ID: (\\d{17,19})"); - private static readonly Regex _vanessaRegex = new Regex("Message sent by <@!?(\\d{17,19})> deleted in"); - private static readonly Regex _salRegex = new Regex("\\(ID: (\\d{17,19})\\)"); - private static readonly Regex _GearBotRegex = new Regex("\\(``(\\d{17,19})``\\) in <#\\d{17,19}> has been removed."); - private static readonly Regex _GiselleRegex = new Regex("\\*\\*Message ID\\*\\*: `(\\d{17,19})`"); + private static readonly Regex _basicRegex = new("(\\d{17,19})"); + private static readonly Regex _dynoRegex = new("Message ID: (\\d{17,19})"); + private static readonly Regex _carlRegex = new("ID: (\\d{17,19})"); + private static readonly Regex _circleRegex = new("\\(`(\\d{17,19})`\\)"); + private static readonly Regex _loggerARegex = new("Message = (\\d{17,19})"); + private static readonly Regex _loggerBRegex = new("MessageID:(\\d{17,19})"); + private static readonly Regex _auttajaRegex = new("Message (\\d{17,19}) deleted"); + private static readonly Regex _mantaroRegex = new("Message \\(?ID:? (\\d{17,19})\\)? created by .* in channel .* was deleted\\."); + private static readonly Regex _pancakeRegex = new("Message from <@(\\d{17,19})> deleted in"); + private static readonly Regex _unbelievaboatRegex = new("Message ID: (\\d{17,19})"); + private static readonly Regex _vanessaRegex = new("Message sent by <@!?(\\d{17,19})> deleted in"); + private static readonly Regex _salRegex = new("\\(ID: (\\d{17,19})\\)"); + private static readonly Regex _GearBotRegex = new("\\(``(\\d{17,19})``\\) in <#\\d{17,19}> has been removed."); + private static readonly Regex _GiselleRegex = new("\\*\\*Message ID\\*\\*: `(\\d{17,19})`"); private static readonly Dictionary _bots = new[] { @@ -57,29 +66,35 @@ namespace PluralKit.Bot .ToDictionary(b => b.WebhookName); private readonly IDatabase _db; - private DiscordShardedClient _client; + private readonly DiscordApiClient _client; + private readonly IDiscordCache _cache; + private readonly Bot _bot; // todo: get rid of this nasty + private readonly ILogger _logger; - public LoggerCleanService(IDatabase db, DiscordShardedClient client) + public LoggerCleanService(IDatabase db, DiscordApiClient client, IDiscordCache cache, Bot bot, ILogger logger) { _db = db; _client = client; + _cache = cache; + _bot = bot; + _logger = logger.ForContext(); } public ICollection Bots => _bots.Values; public async ValueTask HandleLoggerBotCleanup(Message msg) { - // TODO: fix!! - /* - if (msg.Channel.Type != ChannelType.Text) return; - if (!msg.Channel.BotHasAllPermissions(Permissions.ManageMessages)) return; + var channel = _cache.GetChannel(msg.ChannelId); + + if (channel.Type != Channel.ChannelType.GuildText) return; + if (!_bot.PermissionsIn(channel.Id).HasFlag(PermissionSet.ManageMessages)) return; // If this message is from a *webhook*, check if the name matches one of the bots we know // TODO: do we need to do a deeper webhook origin check, or would that be too hard on the rate limit? // If it's from a *bot*, check the bot ID to see if we know it. LoggerBot bot = null; - if (msg.WebhookMessage) _botsByWebhookName.TryGetValue(msg.Author.Username, out bot); - else if (msg.Author.IsBot) _bots.TryGetValue(msg.Author.Id, out bot); + if (msg.WebhookId != null) _botsByWebhookName.TryGetValue(msg.Author.Username, out bot); + else if (msg.Author.Bot) _bots.TryGetValue(msg.Author.Id, out bot); // If we didn't find anything before, or what we found is an unsupported bot, bail if (bot == null) return; @@ -96,33 +111,43 @@ namespace PluralKit.Bot // either way but shouldn't be too much, given it's constrained by user ID and guild. var fuzzy = bot.FuzzyExtractFunc(msg); if (fuzzy == null) return; - + + _logger.Debug("Fuzzy logclean for {BotName} on {MessageId}: {@FuzzyExtractResult}", + bot.Name, msg.Id, fuzzy); + var mid = await _db.Execute(conn => conn.QuerySingleOrDefaultAsync( "select mid from messages where sender = @User and mid > @ApproxID and guild = @Guild limit 1", new { fuzzy.Value.User, - Guild = msg.Channel.GuildId, + Guild = msg.GuildId, ApproxId = DiscordUtils.InstantToSnowflake( - fuzzy.Value.ApproxTimestamp - TimeSpan.FromSeconds(3)) + fuzzy.Value.ApproxTimestamp - Duration.FromSeconds(3)) })); - if (mid == null) return; // If we didn't find a corresponding message, bail + + // If we didn't find a corresponding message, bail + if (mid == null) + return; + // Otherwise, we can *reasonably assume* that this is a logged deletion, so delete the log message. - await msg.DeleteAsync(); + await _client.DeleteMessage(msg.ChannelId, msg.Id); } else if (bot.ExtractFunc != null) { // Other bots give us the message ID itself, and we can just extract that from the database directly. var extractedId = bot.ExtractFunc(msg); if (extractedId == null) return; // If we didn't find anything, bail. + + _logger.Debug("Pure logclean for {BotName} on {MessageId}: {@FuzzyExtractResult}", + bot.Name, msg.Id, extractedId); var mid = await _db.Execute(conn => conn.QuerySingleOrDefaultAsync( "select mid from messages where original_mid = @Mid", new {Mid = extractedId.Value})); if (mid == null) return; // If we've gotten this far, we found a logged deletion of a trigger message. Just yeet it! - await msg.DeleteAsync(); + await _client.DeleteMessage(msg.ChannelId, msg.Id); } // else should not happen, but idk, it might } catch (NotFoundException) @@ -131,10 +156,9 @@ namespace PluralKit.Bot // The only thing I can think of that'd cause this are the DeleteAsync() calls which 404 when // the message doesn't exist anyway - so should be safe to just ignore it, right? } - */ } - private static ulong? ExtractAuttaja(DiscordMessage msg) + private static ulong? ExtractAuttaja(Message msg) { // Auttaja has an optional "compact mode" that logs without embeds // That one puts the ID in the message content, non-compact puts it in the embed description. @@ -146,7 +170,7 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractDyno(DiscordMessage msg) + private static ulong? ExtractDyno(Message msg) { // Embed *description* contains "Message sent by [mention] deleted in [channel]", contains message ID in footer per regex var embed = msg.Embeds.FirstOrDefault(); @@ -155,7 +179,7 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractLoggerA(DiscordMessage msg) + private static ulong? ExtractLoggerA(Message msg) { // This is for Logger#6088 (298822483060981760), distinct from Logger#6278 (327424261180620801). // Embed contains title "Message deleted in [channel]", and an ID field containing both message and user ID (see regex). @@ -169,7 +193,7 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractLoggerB(DiscordMessage msg) + private static ulong? ExtractLoggerB(Message msg) { // This is for Logger#6278 (327424261180620801), distinct from Logger#6088 (298822483060981760). // Embed title ends with "A Message Was Deleted!", footer contains message ID as per regex. @@ -179,7 +203,7 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractGenericBot(DiscordMessage msg) + private static ulong? ExtractGenericBot(Message msg) { // Embed, title is "Message Deleted", ID plain in footer. var embed = msg.Embeds.FirstOrDefault(); @@ -188,7 +212,7 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractBlargBot(DiscordMessage msg) + private static ulong? ExtractBlargBot(Message msg) { // Embed, title ends with "Message Deleted", contains ID plain in a field. var embed = msg.Embeds.FirstOrDefault(); @@ -198,7 +222,7 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractMantaro(DiscordMessage msg) + private static ulong? ExtractMantaro(Message msg) { // Plain message, "Message (ID: [id]) created by [user] (ID: [id]) in channel [channel] was deleted. if (!(msg.Content?.Contains("was deleted.") ?? false)) return null; @@ -206,7 +230,7 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static FuzzyExtractResult? ExtractCarlBot(DiscordMessage msg) + private static FuzzyExtractResult? ExtractCarlBot(Message msg) { // Embed, title is "Message deleted in [channel], **user** ID in the footer, timestamp as, well, timestamp in embed. // This is the *deletion* timestamp, which we can assume is a couple seconds at most after the message was originally sent @@ -214,17 +238,21 @@ namespace PluralKit.Bot if (embed?.Footer == null || embed.Timestamp == null || !(embed.Title?.StartsWith("Message deleted in") ?? false)) return null; var match = _carlRegex.Match(embed.Footer.Text ?? ""); return match.Success - ? new FuzzyExtractResult { User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = embed.Timestamp.Value } + ? new FuzzyExtractResult + { + User = ulong.Parse(match.Groups[1].Value), + ApproxTimestamp = OffsetDateTimePattern.Rfc3339.Parse(embed.Timestamp).GetValueOrThrow().ToInstant() + } : (FuzzyExtractResult?) null; } - private static FuzzyExtractResult? ExtractCircle(DiscordMessage msg) + private static FuzzyExtractResult? ExtractCircle(Message msg) { // Like Auttaja, Circle has both embed and compact modes, but the regex works for both. // Compact: "Message from [user] ([id]) deleted in [channel]", no timestamp (use message time) // Embed: Message Author field: "[user] ([id])", then an embed timestamp string stringWithId = msg.Content; - if (msg.Embeds.Count > 0) + if (msg.Embeds.Length > 0) { var embed = msg.Embeds.First(); if (embed.Author?.Name == null || !embed.Author.Name.StartsWith("Message Deleted in")) return null; @@ -236,11 +264,14 @@ namespace PluralKit.Bot var match = _circleRegex.Match(stringWithId); return match.Success - ? new FuzzyExtractResult {User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = msg.Timestamp} + ? new FuzzyExtractResult { + User = ulong.Parse(match.Groups[1].Value), + ApproxTimestamp = msg.Timestamp().ToInstant() + } : (FuzzyExtractResult?) null; } - private static FuzzyExtractResult? ExtractPancake(DiscordMessage msg) + private static FuzzyExtractResult? ExtractPancake(Message msg) { // Embed, author is "Message Deleted", description includes a mention, timestamp is *message send time* (but no ID) // so we use the message timestamp to get somewhere *after* the message was proxied @@ -248,11 +279,15 @@ namespace PluralKit.Bot if (embed?.Description == null || embed.Author?.Name != "Message Deleted") return null; var match = _pancakeRegex.Match(embed.Description); return match.Success - ? new FuzzyExtractResult {User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = msg.Timestamp} + ? new FuzzyExtractResult + { + User = ulong.Parse(match.Groups[1].Value), + ApproxTimestamp = msg.Timestamp().ToInstant() + } : (FuzzyExtractResult?) null; } - private static ulong? ExtractUnbelievaBoat(DiscordMessage msg) + private static ulong? ExtractUnbelievaBoat(Message msg) { // Embed author is "Message Deleted", footer contains message ID per regex var embed = msg.Embeds.FirstOrDefault(); @@ -261,18 +296,22 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static FuzzyExtractResult? ExtractVanessa(DiscordMessage msg) + private static FuzzyExtractResult? ExtractVanessa(Message msg) { // Title is "Message Deleted", embed description contains mention var embed = msg.Embeds.FirstOrDefault(); if (embed?.Title == null || embed.Title != "Message Deleted" || embed.Description == null) return null; var match = _vanessaRegex.Match(embed.Description); return match.Success - ? new FuzzyExtractResult {User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = msg.Timestamp} + ? new FuzzyExtractResult + { + User = ulong.Parse(match.Groups[1].Value), + ApproxTimestamp = msg.Timestamp().ToInstant() + } : (FuzzyExtractResult?) null; } - private static FuzzyExtractResult? ExtractSAL(DiscordMessage msg) + private static FuzzyExtractResult? ExtractSAL(Message msg) { // Title is "Message Deleted!", field "Message Author" contains ID var embed = msg.Embeds.FirstOrDefault(); @@ -281,22 +320,30 @@ namespace PluralKit.Bot if (authorField == null) return null; var match = _salRegex.Match(authorField.Value); return match.Success - ? new FuzzyExtractResult {User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = msg.Timestamp} + ? new FuzzyExtractResult + { + User = ulong.Parse(match.Groups[1].Value), + ApproxTimestamp = msg.Timestamp().ToInstant() + } : (FuzzyExtractResult?) null; } - private static FuzzyExtractResult? ExtractGearBot(DiscordMessage msg) + private static FuzzyExtractResult? ExtractGearBot(Message msg) { // Simple text based message log. // No message ID, but we have timestamp and author ID. // Not using timestamp here though (seems to be same as message timestamp), might be worth implementing in the future. var match = _GearBotRegex.Match(msg.Content); return match.Success - ? new FuzzyExtractResult {User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = msg.Timestamp} + ? new FuzzyExtractResult + { + User = ulong.Parse(match.Groups[1].Value), + ApproxTimestamp = msg.Timestamp().ToInstant() + } : (FuzzyExtractResult?) null; } - private static ulong? ExtractGiselleBot(DiscordMessage msg) + private static ulong? ExtractGiselleBot(Message msg) { var embed = msg.Embeds.FirstOrDefault(); if (embed?.Title == null || embed.Title != "🗑 Message Deleted") return null; @@ -308,11 +355,11 @@ namespace PluralKit.Bot { public string Name; public ulong Id; - public Func ExtractFunc; - public Func FuzzyExtractFunc; + public Func ExtractFunc; + public Func FuzzyExtractFunc; public string WebhookName; - public LoggerBot(string name, ulong id, Func extractFunc = null, Func fuzzyExtractFunc = null, string webhookName = null) + public LoggerBot(string name, ulong id, Func extractFunc = null, Func fuzzyExtractFunc = null, string webhookName = null) { Name = name; Id = id; @@ -324,8 +371,8 @@ namespace PluralKit.Bot public struct FuzzyExtractResult { - public ulong User; - public DateTimeOffset ApproxTimestamp; + public ulong User { get; set; } + public Instant ApproxTimestamp { get; set; } } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/ShardInfoService.cs b/PluralKit.Bot/Services/ShardInfoService.cs index a89a0bd2..d35cc299 100644 --- a/PluralKit.Bot/Services/ShardInfoService.cs +++ b/PluralKit.Bot/Services/ShardInfoService.cs @@ -1,12 +1,11 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Net.WebSockets; using System.Threading.Tasks; using App.Metrics; -using DSharpPlus; -using DSharpPlus.EventArgs; - using Myriad.Gateway; using NodaTime; @@ -16,6 +15,8 @@ using Serilog; namespace PluralKit.Bot { + // TODO: how much of this do we need now that we have logging in the shard library? + // A lot could probably be cleaned up... public class ShardInfoService { public class ShardInfo @@ -30,10 +31,10 @@ namespace PluralKit.Bot private readonly IMetrics _metrics; private readonly ILogger _logger; - private readonly DiscordShardedClient _client; - private readonly Dictionary _shardInfo = new Dictionary(); + private readonly Cluster _client; + private readonly Dictionary _shardInfo = new(); - public ShardInfoService(ILogger logger, DiscordShardedClient client, IMetrics metrics) + public ShardInfoService(ILogger logger, Cluster client, IMetrics metrics) { _client = client; _metrics = metrics; @@ -44,7 +45,7 @@ namespace PluralKit.Bot { // We initialize this before any shards are actually created and connected // This means the client won't know the shard count, so we attach a listener every time a shard gets connected - _client.SocketOpened += (_, __) => RefreshShardList(); + _client.ShardCreated += InitializeShard; } private void ReportShardStatus() @@ -54,44 +55,40 @@ namespace PluralKit.Bot _metrics.Measure.Gauge.SetValue(BotMetrics.ShardsConnected, _shardInfo.Count(s => s.Value.Connected)); } - private async Task RefreshShardList() + private void InitializeShard(Shard shard) { - // This callback doesn't actually receive the shard that was opening, so we just try to check we have 'em all (so far) - foreach (var (id, shard) in _client.ShardClients) + // Get or insert info in the client dict + if (_shardInfo.TryGetValue(shard.ShardId, out var info)) { - // Get or insert info in the client dict - if (_shardInfo.TryGetValue(id, out var info)) - { - // Skip adding listeners if we've seen this shard & already added listeners to it - if (info.HasAttachedListeners) continue; - } else _shardInfo[id] = info = new ShardInfo(); + // Skip adding listeners if we've seen this shard & already added listeners to it + if (info.HasAttachedListeners) + return; + } else _shardInfo[shard.ShardId] = info = new ShardInfo(); + + // Call our own SocketOpened listener manually (and then attach the listener properly) + SocketOpened(shard); + shard.SocketOpened += () => SocketOpened(shard); + // Register listeners for new shards + _logger.Information("Attaching listeners to new shard #{Shard}", shard.ShardId); + shard.Resumed += () => Resumed(shard); + shard.Ready += () => Ready(shard); + shard.SocketClosed += (closeStatus, message) => SocketClosed(shard, closeStatus, message); + shard.HeartbeatReceived += latency => Heartbeated(shard, latency); - // Call our own SocketOpened listener manually (and then attach the listener properly) - await SocketOpened(shard, null); - shard.SocketOpened += SocketOpened; - - // Register listeners for new shards - _logger.Information("Attaching listeners to new shard #{Shard}", shard.ShardId); - shard.Resumed += Resumed; - shard.Ready += Ready; - shard.SocketClosed += SocketClosed; - shard.Heartbeated += Heartbeated; - - // Register that we've seen it - info.HasAttachedListeners = true; - } + // Register that we've seen it + info.HasAttachedListeners = true; + } - private Task SocketOpened(DiscordClient shard, SocketEventArgs _) + private void SocketOpened(Shard shard) { // We do nothing else here, since this kinda doesn't mean *much*? It's only really started once we get Ready/Resumed // And it doesn't get fired first time around since we don't have time to add the event listener before it's fired' _logger.Information("Shard #{Shard} opened socket", shard.ShardId); - return Task.CompletedTask; } - private ShardInfo TryGetShard(DiscordClient shard) + private ShardInfo TryGetShard(Shard shard) { // If we haven't seen this shard before, add it to the dict! // I don't think this will ever occur since the shard number is constant up-front and we handle those @@ -101,7 +98,7 @@ namespace PluralKit.Bot return info; } - private Task Resumed(DiscordClient shard, ReadyEventArgs e) + private void Resumed(Shard shard) { _logger.Information("Shard #{Shard} resumed connection", shard.ShardId); @@ -109,10 +106,9 @@ namespace PluralKit.Bot // info.LastConnectionTime = SystemClock.Instance.GetCurrentInstant(); info.Connected = true; ReportShardStatus(); - return Task.CompletedTask; } - private Task Ready(DiscordClient shard, ReadyEventArgs e) + private void Ready(Shard shard) { _logger.Information("Shard #{Shard} sent Ready event", shard.ShardId); @@ -120,30 +116,28 @@ namespace PluralKit.Bot info.LastConnectionTime = SystemClock.Instance.GetCurrentInstant(); info.Connected = true; ReportShardStatus(); - return Task.CompletedTask; } - private Task SocketClosed(DiscordClient shard, SocketCloseEventArgs e) + private void SocketClosed(Shard shard, WebSocketCloseStatus closeStatus, string message) { - _logger.Warning("Shard #{Shard} disconnected ({CloseCode}: {CloseMessage})", shard.ShardId, e.CloseCode, e.CloseMessage); + _logger.Warning("Shard #{Shard} disconnected ({CloseCode}: {CloseMessage})", + shard.ShardId, closeStatus, message); var info = TryGetShard(shard); info.DisconnectionCount++; info.Connected = false; ReportShardStatus(); - return Task.CompletedTask; } - private Task Heartbeated(DiscordClient shard, HeartbeatEventArgs e) + private void Heartbeated(Shard shard, TimeSpan latency) { - var latency = Duration.FromMilliseconds(e.Ping); - _logger.Information("Shard #{Shard} received heartbeat (latency: {Latency} ms)", shard.ShardId, latency.Milliseconds); + _logger.Information("Shard #{Shard} received heartbeat (latency: {Latency} ms)", + shard.ShardId, latency.Milliseconds); var info = TryGetShard(shard); - info.LastHeartbeatTime = e.Timestamp.ToInstant(); + info.LastHeartbeatTime = SystemClock.Instance.GetCurrentInstant(); info.Connected = true; - info.ShardLatency = latency; - return Task.CompletedTask; + info.ShardLatency = latency.ToDuration(); } public ShardInfo GetShardInfo(Shard shard) => _shardInfo[shard.ShardId]; From 0c1bb6cc6adb691c36d5aa61598463d7e1102f2d Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 14:42:28 +0100 Subject: [PATCH 11/26] Convert message update handler --- Myriad/Gateway/Events/MessageUpdateEvent.cs | 6 +- PluralKit.Bot/Handlers/MessageEdited.cs | 63 ++++++++++++++------- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/Myriad/Gateway/Events/MessageUpdateEvent.cs b/Myriad/Gateway/Events/MessageUpdateEvent.cs index 63b34c1d..0bd1293b 100644 --- a/Myriad/Gateway/Events/MessageUpdateEvent.cs +++ b/Myriad/Gateway/Events/MessageUpdateEvent.cs @@ -1,10 +1,14 @@ -using Myriad.Utils; +using Myriad.Types; +using Myriad.Utils; namespace Myriad.Gateway { public record MessageUpdateEvent(ulong Id, ulong ChannelId): IGatewayEvent { public Optional Content { get; init; } + public Optional Author { get; init; } + public Optional Member { get; init; } + public Optional Attachments { get; init; } // TODO: lots of partials } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageEdited.cs b/PluralKit.Bot/Handlers/MessageEdited.cs index a88e271f..a00f7b22 100644 --- a/PluralKit.Bot/Handlers/MessageEdited.cs +++ b/PluralKit.Bot/Handlers/MessageEdited.cs @@ -1,10 +1,12 @@ +using System; using System.Threading.Tasks; using App.Metrics; -using DSharpPlus; - +using Myriad.Cache; +using Myriad.Extensions; using Myriad.Gateway; +using Myriad.Types; using PluralKit.Core; @@ -18,9 +20,11 @@ namespace PluralKit.Bot private readonly IDatabase _db; private readonly ModelRepository _repo; private readonly IMetrics _metrics; - private readonly DiscordShardedClient _client; + private readonly Cluster _client; + private readonly IDiscordCache _cache; + private readonly Bot _bot; - public MessageEdited(LastMessageCacheService lastMessageCache, ProxyService proxy, IDatabase db, IMetrics metrics, ModelRepository repo, DiscordShardedClient client) + public MessageEdited(LastMessageCacheService lastMessageCache, ProxyService proxy, IDatabase db, IMetrics metrics, ModelRepository repo, Cluster client, IDiscordCache cache, Bot bot) { _lastMessageCache = lastMessageCache; _proxy = proxy; @@ -28,25 +32,46 @@ namespace PluralKit.Bot _metrics = metrics; _repo = repo; _client = client; + _cache = cache; + _bot = bot; } public async Task Handle(Shard shard, MessageUpdateEvent evt) { - // TODO: fix - // if (evt.Author?.Id == _client.CurrentUser?.Id) return; - // - // // Edit message events sometimes arrive with missing data; double-check it's all there - // if (evt.Message.Content == null || evt.Author == null || evt.Channel.Guild == null) return; - // - // // Only react to the last message in the channel - // if (_lastMessageCache.GetLastMessage(evt.Channel.Id) != evt.Message.Id) return; - // - // // Just run the normal message handling code, with a flag to disable autoproxying - // MessageContext ctx; - // await using (var conn = await _db.Obtain()) - // using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) - // ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id); - // await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: false); + if (evt.Author.Value?.Id == _client.User?.Id) 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 = _cache.GetChannel(evt.ChannelId); + if (channel.Type != Channel.ChannelType.GuildText) + return; + var guild = _cache.GetGuild(channel.GuildId!.Value); + + // Only react to the last message in the channel + if (_lastMessageCache.GetLastMessage(evt.ChannelId) != evt.Id) + return; + + // Just run the normal message handling code, with a flag to disable autoproxying + MessageContext ctx; + await using (var conn = await _db.Obtain()) + using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime)) + ctx = await _repo.GetMessageContext(conn, evt.Author.Value!.Id, channel.GuildId!.Value, evt.ChannelId); + + // 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() + }; + var botPermissions = _bot.PermissionsIn(channel.Id); + await _proxy.HandleIncomingMessage(shard, equivalentEvt, ctx, allowAutoproxy: false, guild: guild, channel: channel, botPermissions: botPermissions); } } } \ No newline at end of file From e06a6ecf85422b63d9c56892016ca89b0fc7efd0 Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 14:44:37 +0100 Subject: [PATCH 12/26] Remove now-unused DiscordUtils functions --- PluralKit.Bot/Utils/DiscordUtils.cs | 129 +--------------------------- 1 file changed, 4 insertions(+), 125 deletions(-) diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index 1581f689..69bf51e6 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -10,7 +9,6 @@ using System.Threading.Tasks; using DSharpPlus; using DSharpPlus.Entities; using DSharpPlus.EventArgs; -using DSharpPlus.Exceptions; using Myriad.Builders; using Myriad.Extensions; @@ -49,11 +47,6 @@ namespace PluralKit.Bot private static readonly FieldInfo _roleIdsField = typeof(DiscordMember).GetField("_role_ids", BindingFlags.NonPublic | BindingFlags.Instance); - - public static string NameAndMention(this DiscordUser user) - { - return $"{user.Username}#{user.Discriminator} ({user.Mention})"; - } public static string NameAndMention(this User user) { @@ -105,19 +98,13 @@ namespace PluralKit.Bot if (channel.Type == ChannelType.Private) return DM_PERMISSIONS; return Permissions.None; } - - public static bool BotHasAllPermissions(this DiscordChannel channel, Permissions permissionSet) => - (BotPermissions(channel) & permissionSet) == permissionSet; - + public static Instant SnowflakeToInstant(ulong snowflake) => Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22); public static ulong InstantToSnowflake(Instant time) => (ulong) (time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds << 22; - - public static ulong InstantToSnowflake(DateTimeOffset time) => - (ulong) (time - new DateTimeOffset(2015, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalMilliseconds << 22; - + public static async Task CreateReactionsBulk(this DiscordApiClient rest, Message msg, string[] reactions) { foreach (var reaction in reactions) @@ -131,29 +118,7 @@ namespace PluralKit.Bot // Workaround for https://github.com/DSharpPlus/DSharpPlus/issues/565 return input?.Replace("%20", "+"); } - - public static Task SendMessageFixedAsync(this DiscordChannel channel, string content = null, - DiscordEmbed embed = null, - IEnumerable mentions = null) => - // Passing an empty list blocks all mentions by default (null allows all through) - channel.SendMessageAsync(content, embed: embed, mentions: mentions ?? new IMention[0]); - - // This doesn't do anything by itself (DiscordMember.SendMessageAsync doesn't take a mentions argument) - // It's just here for consistency so we don't use the standard SendMessageAsync method >.> - public static Task SendMessageFixedAsync(this DiscordMember member, string content = null, - DiscordEmbed embed = null) => - member.SendMessageAsync(content, embed: embed); - - public static bool TryGetCachedUser(this DiscordClient client, ulong id, out DiscordUser user) - { - user = null; - - var cache = (ConcurrentDictionary) typeof(BaseDiscordClient) - .GetProperty("UserCache", BindingFlags.Instance | BindingFlags.NonPublic) - ?.GetValue(client); - return cache != null && cache.TryGetValue(id, out user); - } - + public static uint? ToDiscordColor(this string color) { if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) @@ -244,93 +209,7 @@ namespace PluralKit.Bot // So, surrounding with two backticks, then escaping all backtick pairs makes it impossible(!) to "break out" return $"``{EscapeBacktickPair(input)}``"; } - - public static Task GetUser(this DiscordRestClient client, ulong id) => - WrapDiscordCall(client.GetUserAsync(id)); - - public static Task GetUser(this DiscordClient client, ulong id) => - WrapDiscordCall(client.GetUserAsync(id)); - - public static Task GetChannel(this DiscordRestClient client, ulong id) => - WrapDiscordCall(client.GetChannelAsync(id)); - - public static Task GetChannel(this DiscordClient client, ulong id) => - WrapDiscordCall(client.GetChannelAsync(id)); - - public static Task GetGuild(this DiscordRestClient client, ulong id) => - WrapDiscordCall(client.GetGuildAsync(id)); - - public static Task GetGuild(this DiscordClient client, ulong id) => - WrapDiscordCall(client.GetGuildAsync(id)); - - public static Task GetMember(this DiscordRestClient client, ulong guild, ulong user) - { - async Task Inner() => - await (await client.GetGuildAsync(guild)).GetMemberAsync(user); - - return WrapDiscordCall(Inner()); - } - - public static Task GetMember(this DiscordClient client, ulong guild, ulong user) - { - async Task Inner() => - await (await client.GetGuildAsync(guild)).GetMemberAsync(user); - - return WrapDiscordCall(Inner()); - } - - public static Task GetMember(this DiscordGuild guild, ulong user) => - WrapDiscordCall(guild.GetMemberAsync(user)); - - public static Task GetMessage(this DiscordChannel channel, ulong id) => - WrapDiscordCall(channel.GetMessageAsync(id)); - - public static Task GetMessage(this DiscordRestClient client, ulong channel, ulong message) => - WrapDiscordCall(client.GetMessageAsync(channel, message)); - - public static DiscordGuild GetGuild(this DiscordShardedClient client, ulong id) - { - DiscordGuild guild; - foreach (DiscordClient shard in client.ShardClients.Values) - { - shard.Guilds.TryGetValue(id, out guild); - if (guild != null) return guild; - } - - return null; - } - - public static async Task GetChannel(this DiscordShardedClient client, ulong id, - ulong? guildId = null) - { - // we need to know the channel's guild ID to get the cached guild object, so we grab it from the API - if (guildId == null) - { - var channel = await WrapDiscordCall(client.ShardClients.Values.FirstOrDefault().GetChannelAsync(id)); - if (channel != null) guildId = channel.GuildId; - else return null; // we probably don't have the guild in cache if the API doesn't give it to us - } - - return client.GetGuild(guildId.Value).GetChannel(id); - } - - private static async Task WrapDiscordCall(Task t) - where T: class - { - try - { - return await t; - } - catch (NotFoundException) - { - return null; - } - catch (UnauthorizedException) - { - return null; - } - } - + public static EmbedBuilder WithSimpleLineContent(this EmbedBuilder eb, IEnumerable lines) { static int CharacterLimit(int pageNumber) => From 5a52abed77ad2d85d07f5f2680596dea7933a07d Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 14:50:10 +0100 Subject: [PATCH 13/26] Convert Sentry enrichers --- Myriad/Gateway/Events/MessageUpdateEvent.cs | 1 + PluralKit.Bot/Utils/DiscordUtils.cs | 53 +------------ PluralKit.Bot/Utils/SentryUtils.cs | 83 +++++++++++---------- 3 files changed, 45 insertions(+), 92 deletions(-) diff --git a/Myriad/Gateway/Events/MessageUpdateEvent.cs b/Myriad/Gateway/Events/MessageUpdateEvent.cs index 0bd1293b..09ef4316 100644 --- a/Myriad/Gateway/Events/MessageUpdateEvent.cs +++ b/Myriad/Gateway/Events/MessageUpdateEvent.cs @@ -9,6 +9,7 @@ namespace Myriad.Gateway 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/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index 69bf51e6..28b21ae0 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -30,9 +30,7 @@ namespace PluralKit.Bot public const uint Green = 0x00cc78; public const uint Red = 0xef4b3d; public const uint Gray = 0x979c9f; - - public static Permissions DM_PERMISSIONS = (Permissions) 0b00000_1000110_1011100110000_000000; - + private static readonly Regex USER_MENTION = new Regex("<@!?(\\d{17,19})>"); private static readonly Regex ROLE_MENTION = new Regex("<@&(\\d{17,19})>"); private static readonly Regex EVERYONE_HERE_MENTION = new Regex("@(everyone|here)"); @@ -44,60 +42,11 @@ namespace PluralKit.Bot // corresponding to: https://github.com/Khan/simple-markdown/blob/master/src/index.js#L1489 // I added ? at the start/end; they need to be handled specially later... private static readonly Regex UNBROKEN_LINK_REGEX = new Regex("?"); - - private static readonly FieldInfo _roleIdsField = - typeof(DiscordMember).GetField("_role_ids", BindingFlags.NonPublic | BindingFlags.Instance); public static string NameAndMention(this User user) { return $"{user.Username}#{user.Discriminator} ({user.Mention()})"; } - - // We funnel all "permissions from DiscordMember" calls through here - // This way we can ensure we do the read permission correction everywhere - private static Permissions PermissionsInGuild(DiscordChannel channel, DiscordMember member) - { - ValidateCachedRoles(member); - var permissions = channel.PermissionsFor(member); - - // This method doesn't account for channels without read permissions - // If we don't have read permissions in the channel, we don't have *any* permissions - if ((permissions & Permissions.AccessChannels) != Permissions.AccessChannels) - return Permissions.None; - - return permissions; - } - - // Workaround for DSP internal error - private static void ValidateCachedRoles(DiscordMember member) - { - var roleIdCache = _roleIdsField.GetValue(member) as List; - var currentRoleIds = member.Roles.Where(x => x != null).Select(x => x.Id); - var invalidRoleIds = roleIdCache.Where(x => !currentRoleIds.Contains(x)).ToList(); - roleIdCache.RemoveAll(x => invalidRoleIds.Contains(x)); - } - - - // Same as PermissionsIn, but always synchronous. DiscordUser must be a DiscordMember if channel is in guild. - public static Permissions PermissionsInSync(this DiscordChannel channel, DiscordUser user) - { - if (channel.Guild != null && !(user is DiscordMember)) - throw new ArgumentException("Function was passed a guild channel but a non-member DiscordUser"); - - if (user is DiscordMember m) return PermissionsInGuild(channel, m); - if (channel.Type == ChannelType.Private) return DM_PERMISSIONS; - return Permissions.None; - } - - public static Permissions BotPermissions(this DiscordChannel channel) - { - // TODO: can we get a CurrentMember somehow without a guild context? - // at least, without somehow getting a DiscordClient reference as an arg(which I don't want to do) - if (channel.Guild != null) - return PermissionsInSync(channel, channel.Guild.CurrentMember); - if (channel.Type == ChannelType.Private) return DM_PERMISSIONS; - return Permissions.None; - } public static Instant SnowflakeToInstant(ulong snowflake) => Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22); diff --git a/PluralKit.Bot/Utils/SentryUtils.cs b/PluralKit.Bot/Utils/SentryUtils.cs index 0d9cfb19..ed1bf2f5 100644 --- a/PluralKit.Bot/Utils/SentryUtils.cs +++ b/PluralKit.Bot/Utils/SentryUtils.cs @@ -1,9 +1,6 @@ using System.Collections.Generic; -using System.Linq; - -using DSharpPlus; -using DSharpPlus.EventArgs; +using Myriad.Extensions; using Myriad.Gateway; using Sentry; @@ -15,82 +12,88 @@ namespace PluralKit.Bot void Enrich(Scope scope, Shard shard, T evt); } - public class SentryEnricher //: - // TODO!!! - // ISentryEnricher, - // ISentryEnricher, - // ISentryEnricher, - // ISentryEnricher, - // ISentryEnricher + public class SentryEnricher: + ISentryEnricher, + ISentryEnricher, + ISentryEnricher, + ISentryEnricher, + ISentryEnricher { + private readonly Bot _bot; + + public SentryEnricher(Bot bot) + { + _bot = bot; + } + // TODO: should this class take the Scope by dependency injection instead? // Would allow us to create a centralized "chain of handlers" where this class could just be registered as an entry in - public void Enrich(Scope scope, Shard shard, MessageCreateEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageCreateEvent evt) { - scope.AddBreadcrumb(evt.Message.Content, "event.message", data: new Dictionary + scope.AddBreadcrumb(evt.Content, "event.message", data: new Dictionary { {"user", evt.Author.Id.ToString()}, - {"channel", evt.Channel.Id.ToString()}, - {"guild", evt.Channel.GuildId.ToString()}, - {"message", evt.Message.Id.ToString()}, + {"channel", evt.ChannelId.ToString()}, + {"guild", evt.GuildId.ToString()}, + {"message", evt.Id.ToString()}, }); - scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo.ShardId.ToString()); // Also report information about the bot's permissions in the channel // We get a lot of permission errors so this'll be useful for determining problems - var perms = evt.Channel.BotPermissions(); + var perms = _bot.PermissionsIn(evt.ChannelId); scope.AddBreadcrumb(perms.ToPermissionString(), "permissions"); } - public void Enrich(Scope scope, Shard shard, MessageDeleteEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageDeleteEvent evt) { scope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() { - {"channel", evt.Channel.Id.ToString()}, - {"guild", evt.Channel.GuildId.ToString()}, - {"message", evt.Message.Id.ToString()}, + {"channel", evt.ChannelId.ToString()}, + {"guild", evt.GuildId.ToString()}, + {"message", evt.Id.ToString()}, }); - scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo.ShardId.ToString()); } - public void Enrich(Scope scope, Shard shard, MessageUpdateEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageUpdateEvent evt) { - scope.AddBreadcrumb(evt.Message.Content ?? "", "event.messageEdit", + scope.AddBreadcrumb(evt.Content.Value ?? "", "event.messageEdit", data: new Dictionary() { - {"channel", evt.Channel.Id.ToString()}, - {"guild", evt.Channel.GuildId.ToString()}, - {"message", evt.Message.Id.ToString()} + {"channel", evt.ChannelId.ToString()}, + {"guild", evt.GuildId.Value.ToString()}, + {"message", evt.Id.ToString()} }); - scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo.ShardId.ToString()); } - public void Enrich(Scope scope, Shard shard, MessageBulkDeleteEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageDeleteBulkEvent evt) { scope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() { - {"channel", evt.Channel.Id.ToString()}, - {"guild", evt.Channel.Id.ToString()}, - {"messages", string.Join(",", evt.Messages.Select(m => m.Id))}, + {"channel", evt.ChannelId.ToString()}, + {"guild", evt.GuildId.ToString()}, + {"messages", string.Join(",", evt.Ids)}, }); - scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo.ShardId.ToString()); } - public void Enrich(Scope scope, Shard shard, MessageReactionAddEventArgs evt) + public void Enrich(Scope scope, Shard shard, MessageReactionAddEvent evt) { scope.AddBreadcrumb("", "event.reaction", data: new Dictionary() { - {"user", evt.User.Id.ToString()}, - {"channel", (evt.Channel?.Id ?? 0).ToString()}, - {"guild", (evt.Channel?.GuildId ?? 0).ToString()}, - {"message", evt.Message.Id.ToString()}, + {"user", evt.UserId.ToString()}, + {"channel", evt.ChannelId.ToString()}, + {"guild", (evt.GuildId ?? 0).ToString()}, + {"message", evt.MessageId.ToString()}, {"reaction", evt.Emoji.Name} }); - scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString()); + scope.SetTag("shard", shard.ShardInfo.ShardId.ToString()); } } } \ No newline at end of file From 227d68a2a4e640bf3b0766eb31b9149d86e4a329 Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 14:55:57 +0100 Subject: [PATCH 14/26] Convert event destructuring --- PluralKit.Bot/Init.cs | 3 +- PluralKit.Bot/Tracing/EventDestructuring.cs | 40 ++++++++++----------- PluralKit.Bot/Utils/DiscordUtils.cs | 12 ++----- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/PluralKit.Bot/Init.cs b/PluralKit.Bot/Init.cs index 630fd297..f4b7c6f5 100644 --- a/PluralKit.Bot/Init.cs +++ b/PluralKit.Bot/Init.cs @@ -120,7 +120,8 @@ namespace PluralKit.Bot builder.RegisterModule(new ConfigModule("Bot")); builder.RegisterModule(new LoggingModule("bot", cfg => { - cfg.Destructure.With(); + // TODO: do we need this? + // cfg.Destructure.With(); })); builder.RegisterModule(new MetricsModule()); builder.RegisterModule(); diff --git a/PluralKit.Bot/Tracing/EventDestructuring.cs b/PluralKit.Bot/Tracing/EventDestructuring.cs index fcc655bf..e685c43f 100644 --- a/PluralKit.Bot/Tracing/EventDestructuring.cs +++ b/PluralKit.Bot/Tracing/EventDestructuring.cs @@ -1,19 +1,19 @@ using System.Collections.Generic; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; +using Myriad.Gateway; using Serilog.Core; using Serilog.Events; namespace PluralKit.Bot { + // This class is unused and commented out in Init.cs - it's here from before the lib conversion. Is it needed?? public class EventDestructuring: IDestructuringPolicy { public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, out LogEventPropertyValue result) { - if (!(value is DiscordEventArgs dea)) + if (!(value is IGatewayEvent evt)) { result = null; return false; @@ -21,30 +21,30 @@ namespace PluralKit.Bot var props = new List { - new LogEventProperty("Type", new ScalarValue(dea.EventType())), + new("Type", new ScalarValue(evt.EventType())), }; - void AddMessage(DiscordMessage msg) + void AddMessage(ulong id, ulong channelId, ulong? guildId, ulong? author) { - props.Add(new LogEventProperty("MessageId", new ScalarValue(msg.Id))); - props.Add(new LogEventProperty("ChannelId", new ScalarValue(msg.ChannelId))); - props.Add(new LogEventProperty("GuildId", new ScalarValue(msg.Channel.GuildId))); + props.Add(new LogEventProperty("MessageId", new ScalarValue(id))); + props.Add(new LogEventProperty("ChannelId", new ScalarValue(channelId))); + props.Add(new LogEventProperty("GuildId", new ScalarValue(guildId ?? 0))); - if (msg.Author != null) - props.Add(new LogEventProperty("AuthorId", new ScalarValue(msg.Author.Id))); + if (author != null) + props.Add(new LogEventProperty("AuthorId", new ScalarValue(author))); } - if (value is MessageCreateEventArgs mc) - AddMessage(mc.Message); - else if (value is MessageUpdateEventArgs mu) - AddMessage(mu.Message); - else if (value is MessageDeleteEventArgs md) - AddMessage(md.Message); - else if (value is MessageReactionAddEventArgs mra) + if (value is MessageCreateEvent mc) + AddMessage(mc.Id, mc.ChannelId, mc.GuildId, mc.Author.Id); + else if (value is MessageUpdateEvent mu) + AddMessage(mu.Id, mu.ChannelId, mu.GuildId.Value, mu.Author.Value?.Id); + else if (value is MessageDeleteEvent md) + AddMessage(md.Id, md.ChannelId, md.GuildId, null); + else if (value is MessageReactionAddEvent mra) { - AddMessage(mra.Message); - props.Add(new LogEventProperty("ReactingUserId", new ScalarValue(mra.User.Id))); - props.Add(new LogEventProperty("Emoji", new ScalarValue(mra.Emoji.GetDiscordName()))); + AddMessage(mra.MessageId, mra.ChannelId, mra.GuildId, null); + props.Add(new LogEventProperty("ReactingUserId", new ScalarValue(mra.Emoji))); + props.Add(new LogEventProperty("Emoji", new ScalarValue(mra.Emoji.Name))); } // Want shard last, just for visual reasons diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index 28b21ae0..c581c12b 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -2,16 +2,12 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Reflection; using System.Text.RegularExpressions; using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; - using Myriad.Builders; using Myriad.Extensions; +using Myriad.Gateway; using Myriad.Rest; using Myriad.Rest.Types; using Myriad.Types; @@ -20,8 +16,6 @@ using NodaTime; using PluralKit.Core; -using Permissions = DSharpPlus.Permissions; - namespace PluralKit.Bot { public static class DiscordUtils @@ -190,7 +184,7 @@ namespace PluralKit.Bot return $"<{match.Value}>"; }); - public static string EventType(this DiscordEventArgs evt) => - evt.GetType().Name.Replace("EventArgs", ""); + public static string EventType(this IGatewayEvent evt) => + evt.GetType().Name.Replace("Event", ""); } } From b48a77df8dd2391f989b74519bab6ad44a6c8287 Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 14:59:45 +0100 Subject: [PATCH 15/26] Convert periodic stat collector --- .../Services/PeriodicStatCollector.cs | 39 ++++++------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/PluralKit.Bot/Services/PeriodicStatCollector.cs b/PluralKit.Bot/Services/PeriodicStatCollector.cs index d1b77742..032e5fed 100644 --- a/PluralKit.Bot/Services/PeriodicStatCollector.cs +++ b/PluralKit.Bot/Services/PeriodicStatCollector.cs @@ -1,12 +1,11 @@ -using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; using App.Metrics; using Dapper; -using DSharpPlus; -using DSharpPlus.Entities; +using Myriad.Cache; +using Myriad.Types; using NodaTime.Extensions; using PluralKit.Core; @@ -17,8 +16,8 @@ namespace PluralKit.Bot { public class PeriodicStatCollector { - private readonly DiscordShardedClient _client; private readonly IMetrics _metrics; + private readonly IDiscordCache _cache; private readonly CpuStatService _cpu; private readonly IDatabase _db; @@ -29,14 +28,14 @@ namespace PluralKit.Bot private readonly ILogger _logger; - public PeriodicStatCollector(DiscordShardedClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, CpuStatService cpu, IDatabase db) + public PeriodicStatCollector(IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, CpuStatService cpu, IDatabase db, IDiscordCache cache) { - _client = client; _metrics = metrics; _webhookCache = webhookCache; _countHolder = countHolder; _cpu = cpu; _db = db; + _cache = cache; _logger = logger.ForContext(); } @@ -46,36 +45,22 @@ namespace PluralKit.Bot stopwatch.Start(); // Aggregate guild/channel stats - var guildCount = 0; var channelCount = 0; + // No LINQ today, sorry - foreach (var shard in _client.ShardClients.Values) + await foreach (var guild in _cache.GetAllGuilds()) { - guildCount += shard.Guilds.Count; - foreach (var guild in shard.Guilds.Values) - foreach (var channel in guild.Channels.Values) - if (channel.Type == ChannelType.Text) + guildCount++; + foreach (var channel in _cache.GetGuildChannels(guild.Id)) + { + if (channel.Type == Channel.ChannelType.GuildText) channelCount++; + } } _metrics.Measure.Gauge.SetValue(BotMetrics.Guilds, guildCount); _metrics.Measure.Gauge.SetValue(BotMetrics.Channels, channelCount); - - // Aggregate member stats - var usersKnown = new HashSet(); - var usersOnline = new HashSet(); - foreach (var shard in _client.ShardClients.Values) - foreach (var guild in shard.Guilds.Values) - foreach (var user in guild.Members.Values) - { - usersKnown.Add(user.Id); - if (user.Presence?.Status == UserStatus.Online) - usersOnline.Add(user.Id); - } - - _metrics.Measure.Gauge.SetValue(BotMetrics.MembersTotal, usersKnown.Count); - _metrics.Measure.Gauge.SetValue(BotMetrics.MembersOnline, usersOnline.Count); // Aggregate DB stats var counts = await _db.Execute(c => c.QueryFirstAsync("select (select count(*) from systems) as systems, (select count(*) from members) as members, (select count(*) from switches) as switches, (select count(*) from messages) as messages, (select count(*) from groups) as groups")); From 35433b0d82c4084b921b90addb78f138bfb24d13 Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 15:03:11 +0100 Subject: [PATCH 16/26] Convert a few more things --- .../Exceptions/DiscordRequestException.cs | 34 +++++++++---------- .../CommandSystem/ContextChecksExt.cs | 4 +-- PluralKit.Bot/Modules.cs | 31 ++++------------- PluralKit.Bot/Utils/MiscUtils.cs | 12 +++---- 4 files changed, 31 insertions(+), 50 deletions(-) diff --git a/Myriad/Rest/Exceptions/DiscordRequestException.cs b/Myriad/Rest/Exceptions/DiscordRequestException.cs index 6570ad81..0aa94d98 100644 --- a/Myriad/Rest/Exceptions/DiscordRequestException.cs +++ b/Myriad/Rest/Exceptions/DiscordRequestException.cs @@ -6,14 +6,14 @@ namespace Myriad.Rest.Exceptions { public class DiscordRequestException: Exception { - public DiscordRequestException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError) + public DiscordRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) { - RequestBody = requestBody; + ResponseBody = responseBody; Response = response; ApiError = apiError; } - public string RequestBody { get; init; } = null!; + public string ResponseBody { get; init; } = null!; public HttpResponseMessage Response { get; init; } = null!; public HttpStatusCode StatusCode => Response.StatusCode; @@ -29,43 +29,43 @@ namespace Myriad.Rest.Exceptions public class NotFoundException: DiscordRequestException { - public NotFoundException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( - response, requestBody, apiError) { } + public NotFoundException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError): base( + response, responseBody, apiError) { } } public class UnauthorizedException: DiscordRequestException { - public UnauthorizedException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( - response, requestBody, apiError) { } + public UnauthorizedException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError): base( + response, responseBody, apiError) { } } public class ForbiddenException: DiscordRequestException { - public ForbiddenException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( - response, requestBody, apiError) { } + public ForbiddenException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError): base( + response, responseBody, apiError) { } } public class ConflictException: DiscordRequestException { - public ConflictException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( - response, requestBody, apiError) { } + public ConflictException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError): base( + response, responseBody, apiError) { } } public class BadRequestException: DiscordRequestException { - public BadRequestException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): base( - response, requestBody, apiError) { } + public BadRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError): base( + response, responseBody, apiError) { } } public class TooManyRequestsException: DiscordRequestException { - public TooManyRequestsException(HttpResponseMessage response, string requestBody, DiscordApiError? apiError): - base(response, requestBody, apiError) { } + public TooManyRequestsException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError): + base(response, responseBody, apiError) { } } public class UnknownDiscordRequestException: DiscordRequestException { - public UnknownDiscordRequestException(HttpResponseMessage response, string requestBody, - DiscordApiError? apiError): base(response, requestBody, apiError) { } + public UnknownDiscordRequestException(HttpResponseMessage response, string responseBody, + DiscordApiError? apiError): base(response, responseBody, apiError) { } } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs index 53ae3015..eb1d0e89 100644 --- a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs @@ -1,6 +1,4 @@ -using DSharpPlus; - -using Myriad.Types; +using Myriad.Types; using PluralKit.Core; diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 7229581d..f3265e2c 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -3,9 +3,6 @@ using System.Net.Http; using Autofac; -using DSharpPlus; -using DSharpPlus.EventArgs; - using Myriad.Cache; using Myriad.Gateway; @@ -24,17 +21,6 @@ namespace PluralKit.Bot protected override void Load(ContainerBuilder builder) { // Clients - builder.Register(c => new DiscordConfiguration - { - Token = c.Resolve().Token, - TokenType = TokenType.Bot, - MessageCacheSize = 0, - LargeThreshold = 50, - LoggerFactory = c.Resolve() - }).AsSelf(); - builder.Register(c => new DiscordShardedClient(c.Resolve())).AsSelf().SingleInstance(); - builder.Register(c => new DiscordRestClient(c.Resolve())).AsSelf().SingleInstance(); - builder.Register(c => new GatewaySettings { Token = c.Resolve().Token, @@ -82,9 +68,7 @@ namespace PluralKit.Bot builder.RegisterType().As>(); // Event handler queue - builder.RegisterType>().AsSelf().SingleInstance(); builder.RegisterType>().AsSelf().SingleInstance(); - builder.RegisterType>().AsSelf().SingleInstance(); builder.RegisterType>().AsSelf().SingleInstance(); // Bot services @@ -104,14 +88,13 @@ namespace PluralKit.Bot // Sentry stuff builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope(); - // TODO: - // builder.RegisterType() - // .As>() - // .As>() - // .As>() - // .As>() - // .As>() - // .SingleInstance(); + builder.RegisterType() + .As>() + .As>() + .As>() + .As>() + .As>() + .SingleInstance(); // Proxy stuff builder.RegisterType().AsSelf().SingleInstance(); diff --git a/PluralKit.Bot/Utils/MiscUtils.cs b/PluralKit.Bot/Utils/MiscUtils.cs index 38a86618..3fb3b995 100644 --- a/PluralKit.Bot/Utils/MiscUtils.cs +++ b/PluralKit.Bot/Utils/MiscUtils.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; using System.Net.Sockets; using System.Threading.Tasks; -using DSharpPlus.Exceptions; +using Myriad.Rest.Exceptions; using Newtonsoft.Json; @@ -64,12 +64,12 @@ namespace PluralKit.Bot if (e is JsonReaderException jre && jre.Message == "Unexpected character encountered while parsing value: <. Path '', line 0, position 0.") return false; // And now (2020-05-12), apparently Discord returns these weird responses occasionally. Also not our problem. - if (e is BadRequestException bre && bre.WebResponse.Response.Contains("
nginx
")) return false; - if (e is NotFoundException ne && ne.WebResponse.Response.Contains("
nginx
")) return false; - if (e is UnauthorizedException ue && ue.WebResponse.Response.Contains("
nginx
")) return false; + if (e is BadRequestException bre && bre.ResponseBody.Contains("
nginx
")) return false; + if (e is NotFoundException ne && ne.ResponseBody.Contains("
nginx
")) return false; + if (e is UnauthorizedException ue && ue.ResponseBody.Contains("
nginx
")) return false; - // 500s? also not our problem :^) - if (e is ServerErrorException) return false; + // 5xxs? also not our problem :^) + if (e is UnknownDiscordRequestException udre && (int) udre.StatusCode >= 500) return false; // Webhook server errors are also *not our problem* // (this includes rate limit errors, WebhookRateLimited is a subclass) From 8785354a2b6fc1760e791165857778b27a418e77 Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 15:04:37 +0100 Subject: [PATCH 17/26] Remove D#+ dependency :))))) --- PluralKit.Bot/PluralKit.Bot.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 031a901b..7c06a10c 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -16,8 +16,6 @@ - - From 80c572f5946de42a7c377fc686ad22edf268f05d Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 16:02:34 +0100 Subject: [PATCH 18/26] Fix various bugs and regressions --- Myriad/Rest/Ratelimit/Bucket.cs | 5 +- Myriad/Types/GuildMember.cs | 2 +- PluralKit.Bot/Bot.cs | 10 ++- PluralKit.Bot/CommandSystem/Context.cs | 4 +- PluralKit.Bot/Commands/ImportExport.cs | 1 - PluralKit.Bot/Commands/Misc.cs | 3 +- PluralKit.Bot/Commands/SystemFront.cs | 16 +++-- PluralKit.Bot/Handlers/MessageCreated.cs | 2 +- PluralKit.Bot/Proxy/ProxyService.cs | 61 ++++++++----------- PluralKit.Bot/Services/LogChannelService.cs | 2 - .../Services/WebhookExecutorService.cs | 11 +++- 11 files changed, 60 insertions(+), 57 deletions(-) diff --git a/Myriad/Rest/Ratelimit/Bucket.cs b/Myriad/Rest/Ratelimit/Bucket.cs index 7f49ec33..918b11b2 100644 --- a/Myriad/Rest/Ratelimit/Bucket.cs +++ b/Myriad/Rest/Ratelimit/Bucket.cs @@ -73,11 +73,14 @@ namespace Myriad.Rest.Ratelimit 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 (headerNextReset > _nextReset) + if (_nextReset == null || headerNextReset > _nextReset) { _logger.Debug("{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server (after: {NextResetAfter}, remaining: {Remaining}, local remaining: {LocalRemaining})", Key, Major, headerNextReset, headers.ResetAfter.Value, headers.Remaining, Remaining); diff --git a/Myriad/Types/GuildMember.cs b/Myriad/Types/GuildMember.cs index da25fd65..c08508ff 100644 --- a/Myriad/Types/GuildMember.cs +++ b/Myriad/Types/GuildMember.cs @@ -7,7 +7,7 @@ public record GuildMemberPartial { - public string Nick { get; init; } + public string? Nick { get; init; } public ulong[] Roles { get; init; } public string JoinedAt { get; init; } } diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 17c8bfad..550c8d48 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -14,6 +14,7 @@ using Myriad.Cache; using Myriad.Extensions; using Myriad.Gateway; using Myriad.Rest; +using Myriad.Rest.Exceptions; using Myriad.Types; using NodaTime; @@ -244,9 +245,12 @@ namespace PluralKit.Bot // Once we've sent it to Sentry, report it to the user (if we have permission to) var reportChannel = handler.ErrorChannelFor(evt); - // TODO: ID lookup - // if (reportChannel != null && reportChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks)) - // await _errorMessageService.SendErrorMessage(reportChannel, sentryEvent.EventId.ToString()); + if (reportChannel != null) + { + var botPerms = PermissionsIn(reportChannel.Value); + if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks)) + await _errorMessageService.SendErrorMessage(reportChannel.Value, sentryEvent.EventId.ToString()); + } } } diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index d0135ced..16ac3abd 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -101,9 +101,9 @@ namespace PluralKit.Bot { Content = text, Embed = embed, - AllowedMentions = mentions + // Default to an empty allowed mentions object instead of null (which means no mentions allowed) + AllowedMentions = mentions ?? new AllowedMentions() }); - // TODO: mentions should default to empty and not null? if (embed != null) { diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index fda3afe7..bbc3973a 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -144,7 +144,6 @@ namespace PluralKit.Bot try { var dm = await ctx.RestNew.CreateDm(ctx.AuthorNew.Id); - // TODO: send file var msg = await ctx.RestNew.CreateMessage(dm.Id, new MessageRequest {Content = $"{Emojis.Success} Here you go!"}, diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index 92ea99e4..b663747d 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -84,8 +84,7 @@ namespace PluralKit.Bot { var totalSwitches = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.SwitchCount.Name)?.Value ?? 0; var totalMessages = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.MessageCount.Name)?.Value ?? 0; - // TODO: shard stuff - var shardId = ctx.ShardNew.ShardInfo?.ShardId ?? -1; + var shardId = ctx.ShardNew.ShardInfo.ShardId; var shardTotal = ctx.Cluster.Shards.Count; var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count(); var shardInfo = _shards.GetShardInfo(ctx.ShardNew); diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 47a01d03..3f8cbc0b 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Text; using System.Threading.Tasks; using NodaTime; @@ -70,6 +71,7 @@ namespace PluralKit.Bot embedTitle, async (builder, switches) => { + var sb = new StringBuilder(); foreach (var entry in switches) { var lastSw = entry.LastTime; @@ -98,17 +100,13 @@ namespace PluralKit.Bot stringToAdd = $"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago)\n"; } - - try // Unfortunately the only way to test DiscordEmbedBuilder.Description max length is this - { - // TODO: what is this?? - // builder.Description += stringToAdd; - } - catch (ArgumentException) - { + + if (sb.Length + stringToAdd.Length >= 1024) break; - }// TODO: Make sure this works + sb.Append(stringToAdd); } + + builder.Description(sb.ToString()); } ); } diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index cd5a0f00..b5fc477a 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -58,7 +58,7 @@ namespace PluralKit.Bot public async Task Handle(Shard shard, MessageCreateEvent evt) { if (evt.Author.Id == shard.User?.Id) return; - if (evt.Type != Message.MessageType.Default) return; + if (evt.Type != Message.MessageType.Default && evt.Type != Message.MessageType.Reply) return; if (IsDuplicateMessage(evt)) return; var guild = evt.GuildId != null ? _cache.GetGuild(evt.GuildId.Value) : null; diff --git a/PluralKit.Bot/Proxy/ProxyService.cs b/PluralKit.Bot/Proxy/ProxyService.cs index 0a362ab4..32781447 100644 --- a/PluralKit.Bot/Proxy/ProxyService.cs +++ b/PluralKit.Bot/Proxy/ProxyService.cs @@ -88,7 +88,8 @@ namespace PluralKit.Bot if (ctx.SystemId == null) return false; // Make sure channel is a guild text channel and this is a normal message - if ((channel.Type != Channel.ChannelType.GuildText && channel.Type != Channel.ChannelType.GuildNews) || msg.Type != Message.MessageType.Default) return false; + if (channel.Type != Channel.ChannelType.GuildText && channel.Type != Channel.ChannelType.GuildNews) return false; + if (msg.Type != Message.MessageType.Default && msg.Type != Message.MessageType.Reply) return false; // Make sure author is a normal user if (msg.Author.System == true || msg.Author.Bot || msg.WebhookId != null) return false; @@ -109,12 +110,13 @@ namespace PluralKit.Bot { // Create reply embed var embeds = new List(); - if (trigger.MessageReference?.ChannelId == trigger.ChannelId) + if (trigger.Type == Message.MessageType.Reply && trigger.MessageReference?.ChannelId == trigger.ChannelId) { - var repliedTo = await FetchReplyOriginalMessage(trigger.MessageReference); + var repliedTo = trigger.ReferencedMessage.Value; if (repliedTo != null) { - var embed = CreateReplyEmbed(repliedTo); + var nickname = await FetchReferencedMessageAuthorNickname(trigger, repliedTo); + var embed = CreateReplyEmbed(trigger, repliedTo, nickname); if (embed != null) embeds.Add(embed); } @@ -130,7 +132,7 @@ namespace PluralKit.Bot { GuildId = trigger.GuildId!.Value, ChannelId = trigger.ChannelId, - Name = FixSingleCharacterName(match.Member.ProxyName(ctx)), + Name = match.Member.ProxyName(ctx), AvatarUrl = match.Member.ProxyAvatar(ctx), Content = content, Attachments = trigger.Attachments, @@ -140,39 +142,39 @@ namespace PluralKit.Bot await HandleProxyExecutedActions(shard, conn, ctx, trigger, proxyMessage, match); } - private async Task FetchReplyOriginalMessage(Message.Reference reference) + private async Task FetchReferencedMessageAuthorNickname(Message trigger, Message referenced) { + if (referenced.WebhookId != null) + return null; + try { - var msg = await _rest.GetMessage(reference.ChannelId!.Value, reference.MessageId!.Value); - if (msg == null) - _logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but it was not found", - reference.ChannelId, reference.MessageId); - return msg; + var member = await _rest.GetGuildMember(trigger.GuildId!.Value, referenced.Author.Id); + return member?.Nick; } - catch (UnauthorizedException) + catch (ForbiddenException) { - _logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but bot was not allowed to", - reference.ChannelId, reference.MessageId); + _logger.Warning("Failed to fetch member {UserId} in guild {GuildId} when getting reply nickname, falling back to username", + referenced.Author.Id, trigger.GuildId!.Value); + return null; } - - return null; } - private Embed CreateReplyEmbed(Message original) + private Embed CreateReplyEmbed(Message trigger, Message repliedTo, string? nickname) { - var jumpLink = $"https://discord.com/channels/{original.GuildId}/{original.ChannelId}/{original.Id}"; + // repliedTo doesn't have a GuildId field :/ + var jumpLink = $"https://discord.com/channels/{trigger.GuildId}/{repliedTo.ChannelId}/{repliedTo.Id}"; var content = new StringBuilder(); - var hasContent = !string.IsNullOrWhiteSpace(original.Content); + var hasContent = !string.IsNullOrWhiteSpace(repliedTo.Content); if (hasContent) { - var msg = original.Content; + var msg = repliedTo.Content; if (msg.Length > 100) { - msg = original.Content.Substring(0, 100); - var spoilersInOriginalString = Regex.Matches(original.Content, @"\|\|").Count; + msg = repliedTo.Content.Substring(0, 100); + var spoilersInOriginalString = Regex.Matches(repliedTo.Content, @"\|\|").Count; var spoilersInTruncatedString = Regex.Matches(msg, @"\|\|").Count; if (spoilersInTruncatedString % 2 == 1 && spoilersInOriginalString % 2 == 0) msg += "||"; @@ -181,7 +183,7 @@ namespace PluralKit.Bot content.Append($"**[Reply to:]({jumpLink})** "); content.Append(msg); - if (original.Attachments.Length > 0) + if (repliedTo.Attachments.Length > 0) content.Append($" {Emojis.Paperclip}"); } else @@ -189,11 +191,8 @@ namespace PluralKit.Bot content.Append($"*[(click to see attachment)]({jumpLink})*"); } - // TODO: get the nickname somehow - var username = original.Author.Username; - // var username = original.Member?.Nick ?? original.Author.Username; - - var avatarUrl = $"https://cdn.discordapp.com/avatars/{original.Author.Id}/{original.Author.Avatar}.png"; + var username = nickname ?? repliedTo.Author.Username; + var avatarUrl = $"https://cdn.discordapp.com/avatars/{repliedTo.Author.Id}/{repliedTo.Author.Avatar}.png"; return new Embed { @@ -288,12 +287,6 @@ namespace PluralKit.Bot return true; } - private string FixSingleCharacterName(string proxyName) - { - if (proxyName.Length == 1) return proxyName += "\u17b5"; - else return proxyName; - } - private void CheckProxyNameBoundsOrError(string proxyName) { if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName); diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index d08e42a7..6501181f 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -53,8 +53,6 @@ namespace PluralKit.Bot { } // Send embed! - - // TODO: fix? await using var conn = await _db.Obtain(); var embed = _embed.CreateLoggedMessageEmbed(await _repo.GetSystem(conn, ctx.SystemId.Value), await _repo.GetMember(conn, proxy.Member.Id), hookMessage, trigger.Id, trigger.Author, proxy.Content, diff --git a/PluralKit.Bot/Services/WebhookExecutorService.cs b/PluralKit.Bot/Services/WebhookExecutorService.cs index 005f2b45..9017bc30 100644 --- a/PluralKit.Bot/Services/WebhookExecutorService.cs +++ b/PluralKit.Bot/Services/WebhookExecutorService.cs @@ -87,7 +87,7 @@ namespace PluralKit.Bot var webhookReq = new ExecuteWebhookRequest { - Username = FixClyde(req.Name).Truncate(80), + Username = FixProxyName(req.Name).Truncate(80), Content = content, AllowedMentions = allowedMentions, AvatarUrl = !string.IsNullOrWhiteSpace(req.AvatarUrl) ? req.AvatarUrl : null, @@ -185,6 +185,8 @@ namespace PluralKit.Bot return chunks; } + private string FixProxyName(string name) => FixSingleCharacterName(FixClyde(name)); + private string FixClyde(string name) { static string Replacement(Match m) => m.Groups[1].Value + "\u200A" + m.Groups[2].Value; @@ -193,5 +195,12 @@ namespace PluralKit.Bot // since Discord blocks webhooks containing the word "Clyde"... for some reason. /shrug return Regex.Replace(name, "(c)(lyde)", Replacement, RegexOptions.IgnoreCase); } + + private string FixSingleCharacterName(string proxyName) + { + if (proxyName.Length == 1) + return proxyName + "\u17b5"; + return proxyName; + } } } \ No newline at end of file From ef614d07c36957839f74a3756125f30294bbd56f Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 16:16:52 +0100 Subject: [PATCH 19/26] Do the Big Rename --- PluralKit.Bot/CommandSystem/Context.cs | 30 ++++++++-------- .../CommandSystem/ContextChecksExt.cs | 2 +- .../ContextEntityArgumentsExt.cs | 2 +- PluralKit.Bot/Commands/Autoproxy.cs | 14 ++++---- .../Commands/Avatars/ContextAvatarExt.cs | 2 +- PluralKit.Bot/Commands/CommandTree.cs | 2 +- PluralKit.Bot/Commands/ImportExport.cs | 14 ++++---- PluralKit.Bot/Commands/Member.cs | 2 +- PluralKit.Bot/Commands/MemberAvatar.cs | 16 ++++----- PluralKit.Bot/Commands/MemberEdit.cs | 36 +++++++++---------- PluralKit.Bot/Commands/Misc.cs | 16 ++++----- PluralKit.Bot/Commands/Random.cs | 4 +-- PluralKit.Bot/Commands/ServerConfig.cs | 30 ++++++++-------- PluralKit.Bot/Commands/System.cs | 2 +- PluralKit.Bot/Commands/SystemEdit.cs | 8 ++--- PluralKit.Bot/Commands/SystemLink.cs | 2 +- PluralKit.Bot/Commands/Token.cs | 20 +++++------ PluralKit.Bot/Utils/ContextUtils.cs | 36 +++++++++---------- 18 files changed, 119 insertions(+), 119 deletions(-) diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index 16ac3abd..0ad82b6e 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -21,12 +21,12 @@ namespace PluralKit.Bot { private readonly ILifetimeScope _provider; - private readonly DiscordApiClient _newRest; + private readonly DiscordApiClient _rest; private readonly Cluster _cluster; - private readonly Shard _shardNew; + private readonly Shard _shard; private readonly Guild? _guild; private readonly Channel _channel; - private readonly MessageCreateEvent _messageNew; + private readonly MessageCreateEvent _message; private readonly Parameters _parameters; private readonly MessageContext _messageContext; private readonly PermissionSet _botPermissions; @@ -44,8 +44,8 @@ namespace PluralKit.Bot public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, PKSystem senderSystem, MessageContext messageContext, PermissionSet botPermissions) { - _messageNew = message; - _shardNew = shard; + _message = message; + _shard = shard; _guild = guild; _channel = channel; _senderSystem = senderSystem; @@ -57,7 +57,7 @@ namespace PluralKit.Bot _provider = provider; _commandMessageService = provider.Resolve(); _parameters = new Parameters(message.Content?.Substring(commandParseOffset)); - _newRest = provider.Resolve(); + _rest = provider.Resolve(); _cluster = provider.Resolve(); _botPermissions = botPermissions; @@ -66,20 +66,20 @@ namespace PluralKit.Bot public IDiscordCache Cache => _cache; - public Channel ChannelNew => _channel; - public User AuthorNew => _messageNew.Author; - public GuildMemberPartial MemberNew => _messageNew.Member; + public Channel Channel => _channel; + public User Author => _message.Author; + public GuildMemberPartial Member => _message.Member; - public Message MessageNew => _messageNew; - public Guild GuildNew => _guild; - public Shard ShardNew => _shardNew; + public Message Message => _message; + public Guild Guild => _guild; + public Shard Shard => _shard; public Cluster Cluster => _cluster; public MessageContext MessageContext => _messageContext; public PermissionSet BotPermissions => _botPermissions; public PermissionSet UserPermissions => _userPermissions; - public DiscordApiClient RestNew => _newRest; + public DiscordApiClient Rest => _rest; public PKSystem System => _senderSystem; @@ -97,7 +97,7 @@ namespace PluralKit.Bot if (embed != null && !BotPermissions.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 _newRest.CreateMessage(_channel.Id, new MessageRequest + var msg = await _rest.CreateMessage(_channel.Id, new MessageRequest { Content = text, Embed = embed, @@ -109,7 +109,7 @@ namespace PluralKit.Bot { // 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, AuthorNew.Id); + await _commandMessageService.RegisterMessage(msg.Id, Author.Id); } return msg; diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs index eb1d0e89..e09b816e 100644 --- a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs @@ -8,7 +8,7 @@ namespace PluralKit.Bot { public static Context CheckGuildContext(this Context ctx) { - if (ctx.ChannelNew.GuildId != null) return ctx; + if (ctx.Channel.GuildId != null) return ctx; throw new PKError("This command can not be run in a DM."); } diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 44779677..00acf4b7 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -14,7 +14,7 @@ namespace PluralKit.Bot { var text = ctx.PeekArgument(); if (text.TryParseMention(out var id)) - return await ctx.Cache.GetOrFetchUser(ctx.RestNew, id); + return await ctx.Cache.GetOrFetchUser(ctx.Rest, id); return null; } diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index 15abc6fc..c1d58059 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -89,7 +89,7 @@ namespace PluralKit.Bot { 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.GuildNew.Name.EscapeMarkdown()})"); + .Title($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})"); var fronters = ctx.MessageContext.LastSwitchMembers; var relevantMember = ctx.MessageContext.AutoproxyMode switch @@ -129,7 +129,7 @@ namespace PluralKit.Bot } if (!ctx.MessageContext.AllowAutoproxy) - eb.Field(new("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.AuthorNew.Id}>). To enable it, use `pk;autoproxy account enable`.")); + 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(); } @@ -191,7 +191,7 @@ namespace PluralKit.Bot else { var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled"; - await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.AuthorNew.Id}>."); + await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>."); } } @@ -200,18 +200,18 @@ namespace PluralKit.Bot var statusString = allow ? "enabled" : "disabled"; if (ctx.MessageContext.AllowAutoproxy == allow) { - await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.AuthorNew.Id}>."); + await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>."); return; } var patch = new AccountPatch { AllowAutoproxy = allow }; - await _db.Execute(conn => _repo.UpdateAccount(conn, ctx.AuthorNew.Id, patch)); - await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.AuthorNew.Id}>."); + await _db.Execute(conn => _repo.UpdateAccount(conn, ctx.Author.Id, patch)); + await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); } private Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) { var patch = new SystemGuildPatch {AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember}; - return _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.GuildNew.Id, patch)); + return _db.Execute(conn => _repo.UpsertSystemGuild(conn, 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 98646da2..43207639 100644 --- a/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs +++ b/PluralKit.Bot/Commands/Avatars/ContextAvatarExt.cs @@ -45,7 +45,7 @@ namespace PluralKit.Bot } // If we have an attachment, use that - if (ctx.MessageNew.Attachments.FirstOrDefault() is {} attachment) + if (ctx.Message.Attachments.FirstOrDefault() is {} attachment) { var url = TryRewriteCdnUrl(attachment.ProxyUrl); return new ParsedImage {Url = url, Source = AvatarSource.Attachment}; diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index a8132c5e..f418d784 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -524,7 +524,7 @@ namespace PluralKit.Bot { // Try to resolve the user ID to find the associated account, // so we can print their username. - var user = await ctx.RestNew.GetUser(id); + var user = await ctx.Rest.GetUser(id); if (user != null) return $"Account **{user.Username}#{user.Discriminator}** does not have a system registered."; else diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index bbc3973a..3d289f7d 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -34,7 +34,7 @@ namespace PluralKit.Bot public async Task Import(Context ctx) { - var url = ctx.RemainderOrNull() ?? ctx.MessageNew.Attachments.FirstOrDefault()?.Url; + var url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url; if (url == null) throw Errors.NoImportFilePassed; await ctx.BusyIndicator(async () => @@ -69,7 +69,7 @@ namespace PluralKit.Bot if (!data.Valid) throw Errors.InvalidImportFile; - if (data.LinkedAccounts != null && !data.LinkedAccounts.Contains(ctx.AuthorNew.Id)) + if (data.LinkedAccounts != null && !data.LinkedAccounts.Contains(ctx.Author.Id)) { var msg = $"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?"; if (!await ctx.PromptYesNo(msg)) throw Errors.ImportCancelled; @@ -77,7 +77,7 @@ namespace PluralKit.Bot // If passed system is null, it'll create a new one // (and that's okay!) - var result = await _dataFiles.ImportSystem(data, ctx.System, ctx.AuthorNew.Id); + var result = await _dataFiles.ImportSystem(data, ctx.System, ctx.Author.Id); if (!result.Success) await ctx.Reply($"{Emojis.Error} The provided system profile could not be imported. {result.Message}"); else if (ctx.System == null) @@ -143,15 +143,15 @@ namespace PluralKit.Bot try { - var dm = await ctx.RestNew.CreateDm(ctx.AuthorNew.Id); + var dm = await ctx.Rest.CreateDm(ctx.Author.Id); - var msg = await ctx.RestNew.CreateMessage(dm.Id, + var msg = await ctx.Rest.CreateMessage(dm.Id, new MessageRequest {Content = $"{Emojis.Success} Here you go!"}, new[] {new MultipartFile("system.json", stream)}); - await ctx.RestNew.CreateMessage(dm.Id, new MessageRequest { Content = $"<{msg.Attachments[0].Url}>" }); + 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.ChannelNew.Type != Channel.ChannelType.Dm) + if (ctx.Channel.Type != Channel.ChannelType.Dm) await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 20229368..ffed8d3b 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -71,7 +71,7 @@ namespace PluralKit.Bot public async Task ViewMember(Context ctx, PKMember target) { var system = await _db.Execute(c => _repo.GetSystem(c, target.System)); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.GuildNew, ctx.LookupContextFor(system))); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system))); } public async Task Soulscream(Context ctx, PKMember target) diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index fa08b4b6..4afb6d15 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -25,7 +25,7 @@ namespace PluralKit.Bot 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.GuildNew.Name}**)."); + 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."); } @@ -55,7 +55,7 @@ namespace PluralKit.Bot 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.GuildNew.Name})" : "avatar"; + var field = location == AvatarLocation.Server ? $"server avatar (for {ctx.Guild.Name})" : "avatar"; var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar"; var eb = new EmbedBuilder() @@ -69,14 +69,14 @@ namespace PluralKit.Bot public async Task ServerAvatar(Context ctx, PKMember target) { ctx.CheckGuildContext(); - var guildData = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); + var guildData = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); await AvatarCommandTree(AvatarLocation.Server, ctx, target, guildData); } public async Task Avatar(Context ctx, PKMember target) { - var guildData = ctx.GuildNew != null ? - await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)) + var guildData = ctx.Guild != null ? + await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)) : null; await AvatarCommandTree(AvatarLocation.Member, ctx, target, guildData); @@ -119,8 +119,8 @@ namespace PluralKit.Bot var serverFrag = location switch { - AvatarLocation.Server => $" This avatar will now be used when proxying in this server (**{ctx.GuildNew.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.GuildNew.Name}**), and thus changing the global avatar will have no effect here.", + 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.", _ => "" }; @@ -145,7 +145,7 @@ namespace PluralKit.Bot { case AvatarLocation.Server: var serverPatch = new MemberGuildPatch { AvatarUrl = url }; - return _db.Execute(c => _repo.UpsertMemberGuild(c, target.Id, ctx.GuildNew.Id, serverPatch)); + return _db.Execute(c => _repo.UpsertMemberGuild(c, target.Id, ctx.Guild.Id, serverPatch)); case AvatarLocation.Member: var memberPatch = new MemberPatch { AvatarUrl = url }; return _db.Execute(c => _repo.UpdateMember(c, target.Id, memberPatch)); diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 6dc1f1c6..6d7ba1ee 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -46,11 +46,11 @@ namespace PluralKit.Bot 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.GuildNew != null) + if (ctx.Guild != null) { - var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); + var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); if (memberGuildConfig.DisplayName != null) - await ctx.Reply($"{Emojis.Note} Note that this member has a server name set ({memberGuildConfig.DisplayName}) in this server ({ctx.GuildNew.Name}), and will be proxied using that name here."); + 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."); } } @@ -226,8 +226,8 @@ namespace PluralKit.Bot var lcx = ctx.LookupContextFor(target); MemberGuildSettings memberGuildConfig = null; - if (ctx.GuildNew != null) - memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); + if (ctx.Guild != null) + memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); var eb = new EmbedBuilder() .Title($"Member names") @@ -246,12 +246,12 @@ namespace PluralKit.Bot eb.Field(new("Display Name", target.DisplayName ?? "*(none)*")); } - if (ctx.GuildNew != null) + if (ctx.Guild != null) { if (memberGuildConfig?.DisplayName != null) - eb.Field(new($"Server Name (in {ctx.GuildNew.Name})", $"**{memberGuildConfig.DisplayName}**")); + eb.Field(new($"Server Name (in {ctx.Guild.Name})", $"**{memberGuildConfig.DisplayName}**")); else - eb.Field(new($"Server Name (in {ctx.GuildNew.Name})", memberGuildConfig?.DisplayName ?? "*(none)*")); + eb.Field(new($"Server Name (in {ctx.Guild.Name})", memberGuildConfig?.DisplayName ?? "*(none)*")); } return eb; @@ -262,11 +262,11 @@ namespace PluralKit.Bot async Task PrintSuccess(string text) { var successStr = text; - if (ctx.GuildNew != null) + if (ctx.Guild != null) { - var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); + var memberGuildConfig = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); if (memberGuildConfig.DisplayName != null) - successStr += $" However, this member has a server name set in this server ({ctx.GuildNew.Name}), and will be proxied using that name, \"{memberGuildConfig.DisplayName}\", 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); @@ -311,12 +311,12 @@ namespace PluralKit.Bot ctx.CheckOwnMember(target); var patch = new MemberGuildPatch {DisplayName = null}; - await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.GuildNew.Id, patch)); + await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.Guild.Id, patch)); if (target.DisplayName != null) - await ctx.Reply($"{Emojis.Success} Member server name cleared. This member will now be proxied using their global display name \"{target.DisplayName}\" in this server ({ctx.GuildNew.Name})."); + 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.GuildNew.Name})."); + await ctx.Reply($"{Emojis.Success} Member server name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\" in this server ({ctx.Guild.Name})."); } else if (!ctx.HasNext()) { @@ -333,9 +333,9 @@ namespace PluralKit.Bot var newServerName = ctx.RemainderOrNull(); var patch = new MemberGuildPatch {DisplayName = newServerName}; - await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.GuildNew.Id, patch)); + await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.Guild.Id, patch)); - await ctx.Reply($"{Emojis.Success} Member server name changed. This member will now be proxied using the name \"{newServerName}\" in this server ({ctx.GuildNew.Name})."); + 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})."); } } @@ -415,8 +415,8 @@ namespace PluralKit.Bot // Get guild settings (mostly for warnings and such) MemberGuildSettings guildSettings = null; - if (ctx.GuildNew != null) - guildSettings = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.GuildNew.Id, target.Id)); + if (ctx.Guild != null) + guildSettings = await _db.Execute(c => _repo.GetMemberGuild(c, ctx.Guild.Id, target.Id)); async Task SetAll(PrivacyLevel level) { diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index b663747d..5f89c245 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -84,10 +84,10 @@ namespace PluralKit.Bot { var totalSwitches = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.SwitchCount.Name)?.Value ?? 0; var totalMessages = _metrics.Snapshot.GetForContext("Application").Gauges.FirstOrDefault(m => m.MultidimensionalName == CoreMetrics.MessageCount.Name)?.Value ?? 0; - var shardId = ctx.ShardNew.ShardInfo.ShardId; + var shardId = ctx.Shard.ShardInfo.ShardId; var shardTotal = ctx.Cluster.Shards.Count; var shardUpTotal = _shards.Shards.Where(x => x.Connected).Count(); - var shardInfo = _shards.GetShardInfo(ctx.ShardNew); + var shardInfo = _shards.GetShardInfo(ctx.Shard); var process = Process.GetCurrentProcess(); var memoryUsage = process.WorkingSet64; @@ -106,7 +106,7 @@ namespace PluralKit.Bot { .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", $"{totalSystems:N0} systems, {totalMembers:N0} members, {totalGroups:N0} groups, {totalSwitches:N0} switches, {totalMessages:N0} messages")); - await ctx.RestNew.EditMessage(msg.ChannelId, msg.Id, + await ctx.Rest.EditMessage(msg.ChannelId, msg.Id, new MessageEditRequest {Content = "", Embed = embed.Build()}); } @@ -115,10 +115,10 @@ namespace PluralKit.Bot { Guild guild; GuildMemberPartial senderGuildUser = null; - if (ctx.GuildNew != null && !ctx.HasNext()) + if (ctx.Guild != null && !ctx.HasNext()) { - guild = ctx.GuildNew; - senderGuildUser = ctx.MemberNew; + guild = ctx.Guild; + senderGuildUser = ctx.Member; } else { @@ -128,7 +128,7 @@ namespace PluralKit.Bot { guild = await _rest.GetGuild(guildId); if (guild != null) - senderGuildUser = await _rest.GetGuildMember(guildId, ctx.AuthorNew.Id); + senderGuildUser = await _rest.GetGuildMember(guildId, ctx.Author.Id); if (guild == null || senderGuildUser == null) throw Errors.GuildNotFound(guildId); } @@ -150,7 +150,7 @@ namespace PluralKit.Bot { foreach (var channel in await _rest.GetGuildChannels(guild.Id)) { var botPermissions = _bot.PermissionsIn(channel.Id); - var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.AuthorNew.Id, senderGuildUser.Roles); + var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, senderGuildUser.Roles); if ((userPermissions & PermissionSet.ViewChannel) == 0) { diff --git a/PluralKit.Bot/Commands/Random.cs b/PluralKit.Bot/Commands/Random.cs index 51770206..6c154cbc 100644 --- a/PluralKit.Bot/Commands/Random.cs +++ b/PluralKit.Bot/Commands/Random.cs @@ -38,7 +38,7 @@ namespace PluralKit.Bot throw new PKError("Your system has no members! Please create at least one member before using this command."); var randInt = randGen.Next(members.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.GuildNew, ctx.LookupContextFor(ctx.System))); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); } public async Task Group(Context ctx) @@ -73,7 +73,7 @@ namespace PluralKit.Bot var ms = members.ToList(); var randInt = randGen.Next(ms.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.GuildNew, ctx.LookupContextFor(ctx.System))); + 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 6bcbb5b0..e29db8d4 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -32,7 +32,7 @@ namespace PluralKit.Bot if (await ctx.MatchClear("the server log channel")) { - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.GuildNew.Id, new GuildPatch {LogChannel = null})); + await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, new GuildPatch {LogChannel = null})); await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared."); return; } @@ -43,10 +43,10 @@ namespace PluralKit.Bot Channel channel = null; var channelString = ctx.PeekArgument(); channel = await ctx.MatchChannel(); - if (channel == null || channel.GuildId != ctx.GuildNew.Id) throw Errors.ChannelNotFound(channelString); + if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); var patch = new GuildPatch {LogChannel = channel.Id}; - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.GuildNew.Id, patch)); + await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, patch)); await ctx.Reply($"{Emojis.Success} Proxy logging channel set to #{channel.Name}."); } @@ -56,20 +56,20 @@ namespace PluralKit.Bot var affectedChannels = new List(); if (ctx.Match("all")) - affectedChannels = _cache.GetGuildChannels(ctx.GuildNew.Id).Where(x => x.Type == Channel.ChannelType.GuildText).ToList(); + affectedChannels = _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.GuildNew.Id) throw Errors.ChannelNotFound(channelString); + if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); affectedChannels.Add(channel); } ulong? logChannel = null; await using (var conn = await _db.Obtain()) { - var config = await _repo.GetGuild(conn, ctx.GuildNew.Id); + var config = await _repo.GetGuild(conn, ctx.Guild.Id); logChannel = config.LogChannel; var blacklist = config.LogBlacklist.ToHashSet(); if (enable) @@ -78,7 +78,7 @@ namespace PluralKit.Bot blacklist.UnionWith(affectedChannels.Select(c => c.Id)); var patch = new GuildPatch {LogBlacklist = blacklist.ToArray()}; - await _repo.UpsertGuild(conn, ctx.GuildNew.Id, patch); + await _repo.UpsertGuild(conn, ctx.Guild.Id, patch); } await ctx.Reply( @@ -90,7 +90,7 @@ namespace PluralKit.Bot { ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - var blacklist = await _db.Execute(c => _repo.GetGuild(c, ctx.GuildNew.Id)); + var blacklist = await _db.Execute(c => _repo.GetGuild(c, ctx.Guild.Id)); // Resolve all channels from the cache and order by position var channels = blacklist.Blacklist @@ -106,7 +106,7 @@ namespace PluralKit.Bot } await ctx.Paginate(channels.ToAsyncEnumerable(), channels.Count, 25, - $"Blacklisted channels for {ctx.GuildNew.Name}", + $"Blacklisted channels for {ctx.Guild.Name}", (eb, l) => { string CategoryName(ulong? id) => @@ -140,19 +140,19 @@ namespace PluralKit.Bot var affectedChannels = new List(); if (ctx.Match("all")) - affectedChannels = _cache.GetGuildChannels(ctx.GuildNew.Id).Where(x => x.Type == Channel.ChannelType.GuildText).ToList(); + affectedChannels = _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.GuildNew.Id) throw Errors.ChannelNotFound(channelString); + if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); affectedChannels.Add(channel); } await using (var conn = await _db.Obtain()) { - var guild = await _repo.GetGuild(conn, ctx.GuildNew.Id); + var guild = await _repo.GetGuild(conn, ctx.Guild.Id); var blacklist = guild.Blacklist.ToHashSet(); if (shouldAdd) blacklist.UnionWith(affectedChannels.Select(c => c.Id)); @@ -160,7 +160,7 @@ namespace PluralKit.Bot blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); var patch = new GuildPatch {Blacklist = blacklist.ToArray()}; - await _repo.UpsertGuild(conn, ctx.GuildNew.Id, patch); + await _repo.UpsertGuild(conn, ctx.Guild.Id, patch); } await ctx.Reply($"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist."); @@ -183,7 +183,7 @@ namespace PluralKit.Bot .Title("Log cleanup settings") .Field(new("Supported bots", botList)); - var guildCfg = await _db.Execute(c => _repo.GetGuild(c, ctx.GuildNew.Id)); + var guildCfg = await _db.Execute(c => _repo.GetGuild(c, ctx.Guild.Id)); if (guildCfg.LogCleanupEnabled) eb.Description("Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`."); else @@ -193,7 +193,7 @@ namespace PluralKit.Bot } var patch = new GuildPatch {LogCleanupEnabled = newValue}; - await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.GuildNew.Id, patch)); + await _db.Execute(conn => _repo.UpsertGuild(conn, ctx.Guild.Id, patch)); if (newValue) await ctx.Reply($"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts."); diff --git a/PluralKit.Bot/Commands/System.cs b/PluralKit.Bot/Commands/System.cs index b1575c95..d531196d 100644 --- a/PluralKit.Bot/Commands/System.cs +++ b/PluralKit.Bot/Commands/System.cs @@ -34,7 +34,7 @@ namespace PluralKit.Bot var system = _db.Execute(async c => { var system = await _repo.CreateSystem(c, systemName); - await _repo.AddAccount(c, system.Id, ctx.AuthorNew.Id); + await _repo.AddAccount(c, system.Id, ctx.Author.Id); return system; }); diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index 4f492ea2..7895b932 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -191,7 +191,7 @@ namespace PluralKit.Bot public async Task SystemProxy(Context ctx) { ctx.CheckSystem().CheckGuildContext(); - var gs = await _db.Execute(c => _repo.GetSystemGuild(c, ctx.GuildNew.Id, ctx.System.Id)); + var gs = await _db.Execute(c => _repo.GetSystemGuild(c, ctx.Guild.Id, ctx.System.Id)); bool newValue; if (ctx.Match("on", "enabled", "true", "yes")) newValue = true; @@ -207,12 +207,12 @@ namespace PluralKit.Bot } var patch = new SystemGuildPatch {ProxyEnabled = newValue}; - await _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.GuildNew.Id, patch)); + await _db.Execute(conn => _repo.UpsertSystemGuild(conn, ctx.System.Id, ctx.Guild.Id, patch)); if (newValue) - await ctx.Reply($"Message proxying in this server ({ctx.GuildNew.Name.EscapeMarkdown()}) is now **enabled** for your system."); + await ctx.Reply($"Message proxying in this server ({ctx.Guild.Name.EscapeMarkdown()}) is now **enabled** for your system."); else - await ctx.Reply($"Message proxying in this server ({ctx.GuildNew.Name.EscapeMarkdown()}) is now **disabled** for your system."); + await ctx.Reply($"Message proxying in this server ({ctx.Guild.Name.EscapeMarkdown()}) is now **disabled** for your system."); } public async Task SystemTimezone(Context ctx) diff --git a/PluralKit.Bot/Commands/SystemLink.cs b/PluralKit.Bot/Commands/SystemLink.cs index 24042094..0ebc0d83 100644 --- a/PluralKit.Bot/Commands/SystemLink.cs +++ b/PluralKit.Bot/Commands/SystemLink.cs @@ -49,7 +49,7 @@ namespace PluralKit.Bot ulong id; if (!ctx.HasNext()) - id = ctx.AuthorNew.Id; + id = ctx.Author.Id; else if (!ctx.MatchUserRaw(out id)) throw new PKSyntaxError("You must pass an account to link with (either ID or @mention)."); diff --git a/PluralKit.Bot/Commands/Token.cs b/PluralKit.Bot/Commands/Token.cs index 2c34fe38..a1888fb6 100644 --- a/PluralKit.Bot/Commands/Token.cs +++ b/PluralKit.Bot/Commands/Token.cs @@ -29,21 +29,21 @@ namespace PluralKit.Bot 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.RestNew, ctx.AuthorNew.Id); - await ctx.RestNew.CreateMessage(dm.Id, new MessageRequest + 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:" }); - await ctx.RestNew.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.ChannelNew.Type != Channel.ChannelType.Dm) + if (ctx.Channel.Type != Channel.ChannelType.Dm) await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) { // Can't check for permission errors beforehand, so have to handle here :/ - if (ctx.ChannelNew.Type != Channel.ChannelType.Dm) + if (ctx.Channel.Type != Channel.ChannelType.Dm) await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); } } @@ -69,8 +69,8 @@ namespace PluralKit.Bot 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.RestNew, ctx.AuthorNew.Id); - await ctx.RestNew.CreateMessage(dm.Id, new MessageRequest + 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:" }); @@ -78,16 +78,16 @@ namespace PluralKit.Bot // 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.RestNew.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.ChannelNew.Type != Channel.ChannelType.Dm) + if (ctx.Channel.Type != Channel.ChannelType.Dm) await ctx.Reply($"{Emojis.Success} Check your DMs!"); } catch (UnauthorizedException) { // Can't check for permission errors beforehand, so have to handle here :/ - if (ctx.ChannelNew.Type != Channel.ChannelType.Dm) + if (ctx.Channel.Type != Channel.ChannelType.Dm) await ctx.Reply($"{Emojis.Error} Could not send token in DMs. Are your DMs closed?"); } } diff --git a/PluralKit.Bot/Utils/ContextUtils.cs b/PluralKit.Bot/Utils/ContextUtils.cs index 58b281c5..2472c098 100644 --- a/PluralKit.Bot/Utils/ContextUtils.cs +++ b/PluralKit.Bot/Utils/ContextUtils.cs @@ -31,11 +31,11 @@ namespace PluralKit.Bot { if (matchFlag && ctx.MatchFlag("y", "yes")) return true; else message = await ctx.Reply(msgString, mentions: mentions); var cts = new CancellationTokenSource(); - if (user == null) user = ctx.AuthorNew; + if (user == null) user = ctx.Author; if (timeout == null) timeout = Duration.FromMinutes(5); // "Fork" the task adding the reactions off so we don't have to wait for them to be finished to start listening for presses - await ctx.RestNew.CreateReactionsBulk(message, new[] {Emojis.Success, Emojis.Error}); + await ctx.Rest.CreateReactionsBulk(message, new[] {Emojis.Success, Emojis.Error}); bool ReactionPredicate(MessageReactionAddEvent e) { @@ -88,7 +88,7 @@ namespace PluralKit.Bot { public static async Task ConfirmWithReply(this Context ctx, string expectedReply) { bool Predicate(MessageCreateEvent e) => - e.Author.Id == ctx.AuthorNew.Id && e.ChannelId == ctx.ChannelNew.Id; + e.Author.Id == ctx.Author.Id && e.ChannelId == ctx.Channel.Id; var msg = await ctx.Services.Resolve>() .WaitFor(Predicate, Duration.FromMinutes(1)); @@ -121,12 +121,12 @@ namespace PluralKit.Bot { if (pageCount <= 1) return; // If we only have one (or no) page, don't bother with the reaction/pagination logic, lol string[] botEmojis = { "\u23EA", "\u2B05", "\u27A1", "\u23E9", Emojis.Error }; - var _ = ctx.RestNew.CreateReactionsBulk(msg, botEmojis); // Again, "fork" + var _ = ctx.Rest.CreateReactionsBulk(msg, botEmojis); // Again, "fork" try { var currentPage = 0; while (true) { - var reaction = await ctx.AwaitReaction(msg, ctx.AuthorNew, timeout: Duration.FromMinutes(5)); + var reaction = await ctx.AwaitReaction(msg, ctx.Author, timeout: Duration.FromMinutes(5)); // Increment/decrement page counter based on which reaction was clicked if (reaction.Emoji.Name == "\u23EA") currentPage = 0; // << @@ -140,18 +140,18 @@ namespace PluralKit.Bot { // If we can, remove the user's reaction (so they can press again quickly) if (ctx.BotPermissions.HasFlag(PermissionSet.ManageMessages)) - await ctx.RestNew.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, reaction.UserId); + await ctx.Rest.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, reaction.UserId); // Edit the embed with the new page var embed = await MakeEmbedForPage(currentPage); - await ctx.RestNew.EditMessage(msg.ChannelId, msg.Id, new MessageEditRequest {Embed = embed}); + await ctx.Rest.EditMessage(msg.ChannelId, msg.Id, new MessageEditRequest {Embed = embed}); } } catch (TimeoutException) { // "escape hatch", clean up as if we hit X } if (ctx.BotPermissions.HasFlag(PermissionSet.ManageMessages)) - await ctx.RestNew.DeleteAllReactions(msg.ChannelId, msg.Id); + await ctx.Rest.DeleteAllReactions(msg.ChannelId, msg.Id); } // If we get a "NotFound" error, the message has been deleted and thus not our problem catch (NotFoundException) { } @@ -189,10 +189,10 @@ namespace PluralKit.Bot { // Add back/forward reactions and the actual indicator emojis async Task AddEmojis() { - await ctx.RestNew.CreateReaction(msg.ChannelId, msg.Id, new() { Name = "\u2B05" }); - await ctx.RestNew.CreateReaction(msg.ChannelId, msg.Id, new() { Name = "\u27A1" }); + await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new() { Name = "\u2B05" }); + await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new() { Name = "\u27A1" }); for (int i = 0; i < items.Count; i++) - await ctx.RestNew.CreateReaction(msg.ChannelId, msg.Id, new() { Name = indicators[i] }); + await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new() { Name = indicators[i] }); } var _ = AddEmojis(); // Not concerned about awaiting @@ -200,7 +200,7 @@ namespace PluralKit.Bot { while (true) { // Wait for a reaction - var reaction = await ctx.AwaitReaction(msg, ctx.AuthorNew); + var reaction = await ctx.AwaitReaction(msg, ctx.Author); // If it's a movement reaction, inc/dec the page index if (reaction.Emoji.Name == "\u2B05") currPage -= 1; // < @@ -217,8 +217,8 @@ namespace PluralKit.Bot { if (idx < items.Count) return items[idx]; } - var __ = ctx.RestNew.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, ctx.AuthorNew.Id); - await ctx.RestNew.EditMessage(msg.ChannelId, msg.Id, + var __ = ctx.Rest.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, ctx.Author.Id); + await ctx.Rest.EditMessage(msg.ChannelId, msg.Id, new() { Content = @@ -234,13 +234,13 @@ namespace PluralKit.Bot { async Task AddEmojis() { for (int i = 0; i < items.Count; i++) - await ctx.RestNew.CreateReaction(msg.ChannelId, msg.Id, new() {Name = indicators[i]}); + await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new() {Name = indicators[i]}); } var _ = AddEmojis(); // Then wait for a reaction and return whichever one we found - var reaction = await ctx.AwaitReaction(msg, ctx.AuthorNew,rx => indicators.Contains(rx.Emoji.Name)); + var reaction = await ctx.AwaitReaction(msg, ctx.Author,rx => indicators.Contains(rx.Emoji.Name)); return items[Array.IndexOf(indicators, reaction.Emoji.Name)]; } } @@ -264,12 +264,12 @@ namespace PluralKit.Bot { try { - await Task.WhenAll(ctx.RestNew.CreateReaction(ctx.MessageNew.ChannelId, ctx.MessageNew.Id, new() {Name = emoji}), task); + await Task.WhenAll(ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new() {Name = emoji}), task); return await task; } finally { - var _ = ctx.RestNew.DeleteOwnReaction(ctx.MessageNew.ChannelId, ctx.MessageNew.Id, new() { Name = emoji }); + var _ = ctx.Rest.DeleteOwnReaction(ctx.Message.ChannelId, ctx.Message.Id, new() { Name = emoji }); } } } From 557ec4234e964fd6a313c2275e99f2e9b71b6aab Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 17:56:33 +0100 Subject: [PATCH 20/26] Fix fetching messages we can't access --- PluralKit.Bot/Services/EmbedService.cs | 29 +++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 1efc3506..fd0a971a 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -9,6 +9,7 @@ using Myriad.Builders; using Myriad.Cache; using Myriad.Extensions; using Myriad.Rest; +using Myriad.Rest.Exceptions; using Myriad.Types; using NodaTime; @@ -226,7 +227,16 @@ namespace PluralKit.Bot { { var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Channel); var ctx = LookupContext.ByNonOwner; - var serverMsg = channel != null ? await _rest.GetMessage(msg.Message.Channel, msg.Message.Mid) : null; + + Message serverMsg = null; + try + { + serverMsg = await _rest.GetMessage(msg.Message.Channel, msg.Message.Mid); + } + catch (ForbiddenException) + { + // no permission, couldn't fetch, oh well + } // Need this whole dance to handle cases where: // - the user is deleted (userInfo == null) @@ -237,11 +247,20 @@ namespace PluralKit.Bot { User userInfo = null; if (channel != null) { - var m = await _rest.GetGuildMember(channel.GuildId!.Value, msg.Message.Sender); - if (m != null) + GuildMember member = null; + try + { + member = await _rest.GetGuildMember(channel.GuildId!.Value, msg.Message.Sender); + } + catch (ForbiddenException) + { + // no permission, couldn't fetch, oh well + } + + if (member != null) // Don't do an extra request if we already have this info from the member lookup - userInfo = m.User; - memberInfo = m; + userInfo = member.User; + memberInfo = member; } else userInfo = await _cache.GetOrFetchUser(_rest, msg.Message.Sender); From ccd12df99613d4f196c869cbe1337a7142068bba Mon Sep 17 00:00:00 2001 From: Ske Date: Sun, 31 Jan 2021 17:56:44 +0100 Subject: [PATCH 21/26] Fix removing original reaction --- PluralKit.Bot/Handlers/ReactionAdded.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/PluralKit.Bot/Handlers/ReactionAdded.cs b/PluralKit.Bot/Handlers/ReactionAdded.cs index 36412bce..e14070f0 100644 --- a/PluralKit.Bot/Handlers/ReactionAdded.cs +++ b/PluralKit.Bot/Handlers/ReactionAdded.cs @@ -157,7 +157,6 @@ namespace PluralKit.Bot var guild = _cache.GetGuild(evt.GuildId!.Value); // Try to DM the user info about the message - // var member = await evt.Guild.GetMember(evt.User.Id); try { var dm = await _cache.GetOrCreateDmChannel(_rest, evt.UserId); @@ -220,7 +219,7 @@ namespace PluralKit.Bot private async Task TryRemoveOriginalReaction(MessageReactionAddEvent evt) { if (_bot.PermissionsIn(evt.ChannelId).HasFlag(PermissionSet.ManageMessages)) - await _rest.DeleteOwnReaction(evt.ChannelId, evt.MessageId, evt.Emoji); + await _rest.DeleteUserReaction(evt.ChannelId, evt.MessageId, evt.Emoji, evt.UserId); } } } \ No newline at end of file From e7ae9dbe44108ae7964669b1d1744f63509c7b92 Mon Sep 17 00:00:00 2001 From: Ske Date: Mon, 1 Feb 2021 14:26:39 +0100 Subject: [PATCH 22/26] Respect shard concurrency limit --- Myriad/Gateway/Cluster.cs | 39 ++++++++++++++++++----- Myriad/Types/Gateway/SessionStartLimit.cs | 1 + 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/Myriad/Gateway/Cluster.cs b/Myriad/Gateway/Cluster.cs index 220eadc1..cbb0bd51 100644 --- a/Myriad/Gateway/Cluster.cs +++ b/Myriad/Gateway/Cluster.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Myriad.Types; @@ -46,33 +47,55 @@ namespace Myriad.Gateway public async Task Start(GatewayInfo.Bot info, ClusterSessionState? lastState = null) { if (lastState != null && lastState.Shards.Count == info.Shards) - await Resume(info.Url, lastState); + await Resume(info.Url, lastState, info.SessionStartLimit.MaxConcurrency); else - await Start(info.Url, info.Shards); + await Start(info.Url, info.Shards, info.SessionStartLimit.MaxConcurrency); } - public async Task Resume(string url, ClusterSessionState sessionState) + public async Task Resume(string url, ClusterSessionState sessionState, int concurrency) { _logger.Information("Resuming session with {ShardCount} shards at {Url}", sessionState.Shards.Count, url); foreach (var shardState in sessionState.Shards) CreateAndAddShard(url, shardState.Shard, shardState.Session); - await StartShards(); + await StartShards(concurrency); } - public async Task Start(string url, int shardCount) + public async Task Start(string url, int shardCount, int concurrency) { _logger.Information("Starting {ShardCount} shards at {Url}", shardCount, url); for (var i = 0; i < shardCount; i++) CreateAndAddShard(url, new ShardInfo(i, shardCount), null); - await StartShards(); + await StartShards(concurrency); } - private async Task StartShards() + private async Task StartShards(int concurrency) { + var lastTime = DateTimeOffset.UtcNow; + var identifyCalls = 0; + _logger.Information("Connecting shards..."); - await Task.WhenAll(_shards.Values.Select(s => s.Start())); + foreach (var shard in _shards.Values) + { + if (identifyCalls >= concurrency) + { + var timeout = lastTime + TimeSpan.FromSeconds(5.5); + var delay = timeout - DateTimeOffset.UtcNow; + + if (delay > TimeSpan.Zero) + { + _logger.Information("Hit shard concurrency limit, waiting {Delay}", delay); + await Task.Delay(delay); + } + + identifyCalls = 0; + lastTime = DateTimeOffset.UtcNow; + } + + await shard.Start(); + identifyCalls++; + } } private void CreateAndAddShard(string url, ShardInfo shardInfo, ShardSessionInfo? session) diff --git a/Myriad/Types/Gateway/SessionStartLimit.cs b/Myriad/Types/Gateway/SessionStartLimit.cs index 381c7cd9..b5da4770 100644 --- a/Myriad/Types/Gateway/SessionStartLimit.cs +++ b/Myriad/Types/Gateway/SessionStartLimit.cs @@ -5,5 +5,6 @@ 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 From ef9b69a99737d96ac05443eb484f46da4458be2a Mon Sep 17 00:00:00 2001 From: Ske Date: Mon, 1 Feb 2021 14:26:51 +0100 Subject: [PATCH 23/26] Fix some grammar in group member add/remove --- PluralKit.Bot/Utils/MiscUtils.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Utils/MiscUtils.cs b/PluralKit.Bot/Utils/MiscUtils.cs index 3fb3b995..041f7ae7 100644 --- a/PluralKit.Bot/Utils/MiscUtils.cs +++ b/PluralKit.Bot/Utils/MiscUtils.cs @@ -46,7 +46,7 @@ namespace PluralKit.Bot if (notActionedOn == 0) return $"{Emojis.Success} {entityTerm(actionedOn.Count, true)} {opStr} {entityTerm(actionedOn.Count, false).ToLower()}."; else - return $"{Emojis.Success} {entityTerm(actionedOn.Count, true)} {opStr} {actionedOn.Count} {entityTerm(actionedOn.Count, false).ToLower()} ({memberNotActionedPosStr}{entityTerm(actionedOn.Count, true).ToLower()} already {inStr} {groupNotActionedPosStr}{entityTerm(notActionedOn, false).ToLower()})."; + return $"{Emojis.Success} {actionedOn.Count} {entityTerm(actionedOn.Count, true).ToLower()} {opStr} {entityTerm(actionedOn.Count, false).ToLower()} ({memberNotActionedPosStr}{entityTerm(actionedOn.Count, true).ToLower()} already {inStr} {groupNotActionedPosStr}{entityTerm(notActionedOn, false).ToLower()})."; } public static bool IsOurProblem(this Exception e) From 18cf8638342d257ea4c58c54c3aa567061fec165 Mon Sep 17 00:00:00 2001 From: Ske Date: Mon, 8 Feb 2021 16:30:18 +0100 Subject: [PATCH 24/26] Make rate limit parser more resilient --- Myriad/Rest/Ratelimit/RatelimitHeaders.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Myriad/Rest/Ratelimit/RatelimitHeaders.cs b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs index 4a867deb..5387a10a 100644 --- a/Myriad/Rest/Ratelimit/RatelimitHeaders.cs +++ b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs @@ -13,22 +13,27 @@ namespace Myriad.Rest.Ratelimit ServerDate = response.Headers.Date; if (response.Headers.TryGetValues("X-RateLimit-Limit", out var limit)) - Limit = int.Parse(limit!.First()); + if (int.TryParse(limit.First(), out var limitNum)) + Limit = limitNum; if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining)) - Remaining = int.Parse(remaining!.First()); + if (int.TryParse(remaining!.First(), out var remainingNum)) + Remaining = remainingNum; if (response.Headers.TryGetValues("X-RateLimit-Reset", out var reset)) - Reset = DateTimeOffset.FromUnixTimeMilliseconds((long) (double.Parse(reset!.First()) * 1000)); + if (double.TryParse(reset!.First(), out var resetNum)) + Reset = DateTimeOffset.FromUnixTimeMilliseconds((long) (resetNum * 1000)); if (response.Headers.TryGetValues("X-RateLimit-Reset-After", out var resetAfter)) - ResetAfter = TimeSpan.FromSeconds(double.Parse(resetAfter!.First())); + if (double.TryParse(resetAfter!.First(), out var resetAfterNum)) + ResetAfter = TimeSpan.FromSeconds(resetAfterNum); if (response.Headers.TryGetValues("X-RateLimit-Bucket", out var bucket)) Bucket = bucket.First(); if (response.Headers.TryGetValues("X-RateLimit-Global", out var global)) - Global = bool.Parse(global!.First()); + if (bool.TryParse(global!.First(), out var globalBool)) + Global = globalBool; } public bool Global { get; init; } From 74424edc8956fef5a31b3bef239e42789a158f4f Mon Sep 17 00:00:00 2001 From: Ske Date: Mon, 8 Feb 2021 19:53:06 +0100 Subject: [PATCH 25/26] Refactor rate limit parser, fix locale also --- .../Rest/Ratelimit/DiscordRateLimitPolicy.cs | 2 +- Myriad/Rest/Ratelimit/RatelimitHeaders.cs | 108 ++++++++++++------ 2 files changed, 72 insertions(+), 38 deletions(-) diff --git a/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs b/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs index 9c9e2d00..0ec340e1 100644 --- a/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs +++ b/Myriad/Rest/Ratelimit/DiscordRateLimitPolicy.cs @@ -37,7 +37,7 @@ namespace Myriad.Rest.Ratelimit var response = await action(context, ct).ConfigureAwait(continueOnCapturedContext); // Update rate limit state with headers - var headers = new RatelimitHeaders(response); + var headers = RatelimitHeaders.Parse(response); _ratelimiter.HandleResponse(headers, endpoint, major); return response; diff --git a/Myriad/Rest/Ratelimit/RatelimitHeaders.cs b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs index 5387a10a..581b360e 100644 --- a/Myriad/Rest/Ratelimit/RatelimitHeaders.cs +++ b/Myriad/Rest/Ratelimit/RatelimitHeaders.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using System.Net.Http; @@ -6,46 +7,79 @@ namespace Myriad.Rest.Ratelimit { public record RatelimitHeaders { - public 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 RatelimitHeaders(HttpResponseMessage response) - { - ServerDate = response.Headers.Date; - - if (response.Headers.TryGetValues("X-RateLimit-Limit", out var limit)) - if (int.TryParse(limit.First(), out var limitNum)) - Limit = limitNum; - - if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining)) - if (int.TryParse(remaining!.First(), out var remainingNum)) - Remaining = remainingNum; - - if (response.Headers.TryGetValues("X-RateLimit-Reset", out var reset)) - if (double.TryParse(reset!.First(), out var resetNum)) - Reset = DateTimeOffset.FromUnixTimeMilliseconds((long) (resetNum * 1000)); - - if (response.Headers.TryGetValues("X-RateLimit-Reset-After", out var resetAfter)) - if (double.TryParse(resetAfter!.First(), out var resetAfterNum)) - ResetAfter = TimeSpan.FromSeconds(resetAfterNum); - - if (response.Headers.TryGetValues("X-RateLimit-Bucket", out var bucket)) - Bucket = bucket.First(); - - if (response.Headers.TryGetValues("X-RateLimit-Global", out var global)) - if (bool.TryParse(global!.First(), out var globalBool)) - Global = globalBool; - } - - public bool Global { get; init; } - public int? Limit { get; init; } - public int? Remaining { get; init; } - public DateTimeOffset? Reset { get; init; } - public TimeSpan? ResetAfter { get; init; } - public string? Bucket { get; init; } - - public DateTimeOffset? ServerDate { get; init; } + 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 + { + ServerDate = response.Headers.Date, + Limit = TryGetInt(response, LimitHeader), + Remaining = TryGetInt(response, RemainingHeader), + Bucket = TryGetHeader(response, BucketHeader) + }; + + + var resetTimestamp = TryGetDouble(response, ResetHeader); + if (resetTimestamp != null) + headers.Reset = DateTimeOffset.FromUnixTimeMilliseconds((long) (resetTimestamp.Value * 1000)); + + var resetAfterSeconds = TryGetDouble(response, ResetAfterHeader); + if (resetAfterSeconds != null) + headers.ResetAfter = TimeSpan.FromSeconds(resetAfterSeconds.Value); + + var global = TryGetHeader(response, GlobalHeader); + if (global != null && bool.TryParse(global, out var globalBool)) + headers.Global = globalBool; + + return headers; + } + + private static string? TryGetHeader(HttpResponseMessage response, string headerName) + { + if (!response.Headers.TryGetValues(headerName, out var values)) + return null; + + return values.FirstOrDefault(); + } + + private static int? TryGetInt(HttpResponseMessage response, string headerName) + { + var valueString = TryGetHeader(response, headerName); + + if (!int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + return null; + + return value; + } + + private static double? TryGetDouble(HttpResponseMessage response, string headerName) + { + var valueString = TryGetHeader(response, headerName); + + if (!double.TryParse(valueString, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + return null; + + return value; + } } } \ No newline at end of file From 9d80b7b141285869aec6155676b34b9984da3182 Mon Sep 17 00:00:00 2001 From: Spectralitree Date: Tue, 9 Feb 2021 23:36:43 +0100 Subject: [PATCH 26/26] Add group front percentages Also add a title to the system frontpercent embed, and tweak the footer --- PluralKit.Bot/Commands/CommandTree.cs | 5 +++- PluralKit.Bot/Commands/Groups.cs | 27 +++++++++++++++++++ PluralKit.Bot/Commands/SystemFront.cs | 10 +++++-- PluralKit.Bot/Services/EmbedService.cs | 5 ++-- .../Repository/ModelRepository.Switch.cs | 15 ++++++++--- PluralKit.Core/Services/DataFileService.cs | 2 +- 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index f418d784..cfd59279 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -60,6 +60,7 @@ namespace PluralKit.Bot public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); public static Command GroupIcon = new Command("group icon", "group icon [url|@mention]", "Changes a group's icon"); public static Command GroupDelete = new Command("group delete", "group delete", "Deletes a group"); + public static Command 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"); @@ -107,7 +108,7 @@ namespace PluralKit.Bot public static Command[] GroupCommandsTargeted = { GroupInfo, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, GroupIcon, GroupPrivacy, - GroupDelete, GroupMemberRandom + GroupDelete, GroupMemberRandom, GroupFrontPercent }; public static Command[] SwitchCommands = {Switch, SwitchOut, SwitchMove, SwitchDelete, SwitchDeleteAll}; @@ -397,6 +398,8 @@ namespace PluralKit.Bot 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("fp", "frontpercent", "front%", "frontbreakdown")) + await ctx.Execute(GroupFrontPercent, g => g.GroupFrontPercent(ctx, target)); else if (!ctx.HasNext()) await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); else diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index ce93f8fe..4259dff8 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -8,6 +8,8 @@ using Dapper; using Humanizer; +using NodaTime; + using Myriad.Builders; using PluralKit.Core; @@ -428,6 +430,31 @@ namespace PluralKit.Bot await ctx.Reply($"{Emojis.Success} Group deleted."); } + public async Task GroupFrontPercent(Context ctx, PKGroup target) + { + await using var conn = await _db.Obtain(); + + var targetSystem = await GetGroupSystem(ctx, target, conn); + ctx.CheckSystemPrivacy(targetSystem, targetSystem.FrontHistoryPrivacy); + + 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 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())); + } + private async Task GetGroupSystem(Context ctx, PKGroup target, IPKConnection conn) { var system = ctx.System; diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 3f8cbc0b..d9531216 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -124,8 +124,14 @@ namespace PluralKit.Bot if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; - var frontpercent = await _db.Execute(c => _repo.GetFrontBreakdown(c, system.Id, rangeStart.Value.ToInstant(), now)); - await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system.Zone, ctx.LookupContextFor(system))); + var title = new StringBuilder($"Frontpercent of "); + if (system.Name != null) + title.Append($"{system.Name} (`{system.Hid}`)"); + else + title.Append($"`{system.Hid}`"); + + 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())); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index fd0a971a..5528f1cf 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -293,12 +293,13 @@ namespace PluralKit.Bot { return eb.Build(); } - public Task CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz, LookupContext ctx) + public Task CreateFrontPercentEmbed(FrontBreakdown breakdown, PKSystem system, PKGroup group, DateTimeZone tz, LookupContext ctx, string embedTitle) { var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart; var eb = new EmbedBuilder() + .Title(embedTitle) .Color(DiscordUtils.Gray) - .Footer(new($"Since {breakdown.RangeStart.FormatZoned(tz)} ({actualPeriod.FormatDuration()} ago)")); + .Footer(new($"System ID: {system.Hid} | {(group != null ? $"Group ID: {group.Hid} | " : "") }Since {breakdown.RangeStart.FormatZoned(tz)} ({actualPeriod.FormatDuration()} ago)")); var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others" diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs b/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs index f313f00a..d8633acf 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs @@ -122,7 +122,7 @@ namespace PluralKit.Core await GetSwitches(conn, system).FirstOrDefaultAsync(); public async Task> GetPeriodFronters(IPKConnection conn, - SystemId system, Instant periodStart, + SystemId system, GroupId? group, Instant periodStart, Instant periodEnd) { // TODO: IAsyncEnumerable-ify this one @@ -139,7 +139,14 @@ namespace PluralKit.Core new {Switches = switchMembers.Select(m => m.Member.Value).Distinct().ToList()}); var memberObjects = membersList.ToDictionary(m => m.Id); + // check if a group ID is provided. if so, query DB for all members of said group, otherwise use membersList + var groupMembersList = group != null ? await conn.QueryAsync( + "select * from members inner join group_members on members.id = group_members.member_id where group_id = @id", + new {id = group}) : membersList; + var groupMemberObjects = groupMembersList.ToDictionary(m => m.Id); + // Initialize entries - still need to loop to determine the TimespanEnd below + // use groupMemberObjects to make sure no members outside of the specified group (if present) are selected var entries = from item in switchMembers group item by item.Timestamp @@ -147,7 +154,7 @@ namespace PluralKit.Core select new SwitchListEntry { TimespanStart = g.Key, - Members = g.Where(x => x.Member != default(MemberId)).Select(x => memberObjects[x.Member]) + Members = g.Where(x => x.Member != default(MemberId) && groupMemberObjects.Any(m => x.Member == m.Key) ).Select(x => memberObjects[x.Member]) .ToList() }; @@ -174,7 +181,7 @@ namespace PluralKit.Core return outList; } - public async Task GetFrontBreakdown(IPKConnection conn, SystemId system, Instant periodStart, + public async Task GetFrontBreakdown(IPKConnection conn, SystemId system, GroupId? group, Instant periodStart, Instant periodEnd) { // TODO: this doesn't belong in the repo @@ -188,7 +195,7 @@ namespace PluralKit.Core var actualStart = periodEnd; // will be "pulled" down var actualEnd = periodStart; // will be "pulled" up - foreach (var sw in await GetPeriodFronters(conn, system, periodStart, periodEnd)) + foreach (var sw in await GetPeriodFronters(conn, system, group, periodStart, periodEnd)) { var span = sw.TimespanEnd - sw.TimespanStart; foreach (var member in sw.Members) diff --git a/PluralKit.Core/Services/DataFileService.cs b/PluralKit.Core/Services/DataFileService.cs index c694a156..0601efd9 100644 --- a/PluralKit.Core/Services/DataFileService.cs +++ b/PluralKit.Core/Services/DataFileService.cs @@ -51,7 +51,7 @@ namespace PluralKit.Core // Export switches var switches = new List(); - var switchList = await _repo.GetPeriodFronters(conn, system.Id, Instant.FromDateTimeUtc(DateTime.MinValue.ToUniversalTime()), SystemClock.Instance.GetCurrentInstant()); + var switchList = await _repo.GetPeriodFronters(conn, system.Id, null, Instant.FromDateTimeUtc(DateTime.MinValue.ToUniversalTime()), SystemClock.Instance.GetCurrentInstant()); switches.AddRange(switchList.Select(x => new DataFileSwitch { Timestamp = x.TimespanStart.FormatExport(),