From a6fbd869be4e9dd931cb48f4970f697f516030e5 Mon Sep 17 00:00:00 2001 From: Ske Date: Tue, 22 Dec 2020 13:15:26 +0100 Subject: [PATCH] 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