From 55209a0517f217e29b9e5ab5976ae4b320dc9450 Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Thu, 23 Feb 2023 00:54:02 +0200 Subject: [PATCH] Add support for member-level avatars --- .../Discord/Data/Channel.cs | 32 +++++----- .../Discord/Data/ChannelCategory.cs | 2 - .../Discord/Data/Common/ImageCdn.cs | 60 +++++++++++++++++++ .../Discord/Data/Emoji.cs | 37 +++--------- .../{Utils => Discord/Data}/EmojiIndex.cs | 0 .../Discord/Data/Guild.cs | 25 +++----- .../Discord/Data/Member.cs | 15 ++++- .../Discord/Data/Sticker.cs | 19 +++--- .../Discord/Data/StickerFormat.cs | 2 +- DiscordChatExporter.Core/Discord/Data/User.cs | 22 ++----- .../Discord/DiscordClient.cs | 4 +- .../Exporting/JsonMessageWriter.cs | 5 +- .../Exporting/MessageGroupTemplate.cshtml | 12 ++-- 13 files changed, 133 insertions(+), 102 deletions(-) create mode 100644 DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs rename DiscordChatExporter.Core/{Utils => Discord/Data}/EmojiIndex.cs (100%) diff --git a/DiscordChatExporter.Core/Discord/Data/Channel.cs b/DiscordChatExporter.Core/Discord/Data/Channel.cs index 4ac1acc..0a21c21 100644 --- a/DiscordChatExporter.Core/Discord/Data/Channel.cs +++ b/DiscordChatExporter.Core/Discord/Data/Channel.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Text.Json; using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Utils.Extensions; @@ -37,20 +36,16 @@ public partial record Channel null ); - private static string GetIconUrl(Snowflake id, string iconHash) - { - var extension = iconHash.StartsWith("a_", StringComparison.Ordinal) - ? "gif" - : "png"; - - return $"https://cdn.discordapp.com/channel-icons/{id}/{iconHash}.{extension}"; - } - - public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? positionHint = null) + public static Channel Parse(JsonElement json, ChannelCategory? categoryHint = null, int? positionHint = null) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var kind = (ChannelKind)json.GetProperty("type").GetInt32(); - var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); + + var guildId = + json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ?? + Guild.DirectMessages.Id; + + var category = categoryHint ?? GetFallbackCategory(kind); var name = // Guild channel @@ -70,8 +65,11 @@ public partial record Channel positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull(); - // Only available on group DMs - var iconUrl = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h)); + // Icons can only be set for group DM channels + var iconUrl = json + .GetPropertyOrNull("icon")? + .GetNonWhiteSpaceStringOrNull()? + .Pipe(h => ImageCdn.GetChannelIconUrl(id, h)); var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull(); @@ -83,8 +81,8 @@ public partial record Channel return new Channel( id, kind, - guildId ?? Guild.DirectMessages.Id, - category ?? GetFallbackCategory(kind), + guildId, + category, name, position, iconUrl, diff --git a/DiscordChatExporter.Core/Discord/Data/ChannelCategory.cs b/DiscordChatExporter.Core/Discord/Data/ChannelCategory.cs index 90ce05b..f7a9087 100644 --- a/DiscordChatExporter.Core/Discord/Data/ChannelCategory.cs +++ b/DiscordChatExporter.Core/Discord/Data/ChannelCategory.cs @@ -7,8 +7,6 @@ namespace DiscordChatExporter.Core.Discord.Data; public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId { - public static ChannelCategory Unknown { get; } = new(Snowflake.Zero, "", 0); - public static ChannelCategory Parse(JsonElement json, int? positionHint = null) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); diff --git a/DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs b/DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs new file mode 100644 index 0000000..0078ded --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using DiscordChatExporter.Core.Utils.Extensions; + +namespace DiscordChatExporter.Core.Discord.Data.Common; + +// https://discord.com/developers/docs/reference#image-formatting +public static class ImageCdn +{ + // Standard emoji are rendered through Twemoji + public static string GetStandardEmojiUrl(string emojiName) + { + var runes = emojiName.GetRunes().ToArray(); + + // Variant selector rune is skipped in Twemoji IDs, + // except when the emoji also contains a zero-width joiner. + // VS = 0xfe0f; ZWJ = 0x200d. + var filteredRunes = runes.Any(r => r.Value == 0x200d) + ? runes + : runes.Where(r => r.Value != 0xfe0f); + + var twemojiId = string.Join( + "-", + filteredRunes.Select(r => r.Value.ToString("x")) + ); + + return $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{twemojiId}.svg"; + } + + public static string GetCustomEmojiUrl(Snowflake emojiId, bool isAnimated = false) => + isAnimated + ? $"https://cdn.discordapp.com/emojis/{emojiId}.gif" + : $"https://cdn.discordapp.com/emojis/{emojiId}.png"; + + public static string GetGuildIconUrl(Snowflake guildId, string iconHash, int size = 512) => + iconHash.StartsWith("a_", StringComparison.Ordinal) + ? $"https://cdn.discordapp.com/icons/{guildId}/{iconHash}.gif?size={size}" + : $"https://cdn.discordapp.com/icons/{guildId}/{iconHash}.png?size={size}"; + + public static string GetChannelIconUrl(Snowflake channelId, string iconHash, int size = 512) => + iconHash.StartsWith("a_", StringComparison.Ordinal) + ? $"https://cdn.discordapp.com/channel-icons/{channelId}/{iconHash}.gif?size={size}" + : $"https://cdn.discordapp.com/channel-icons/{channelId}/{iconHash}.png?size={size}"; + + public static string GetUserAvatarUrl(Snowflake userId, string avatarHash, int size = 512) => + avatarHash.StartsWith("a_", StringComparison.Ordinal) + ? $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.gif?size={size}" + : $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.png?size={size}"; + + public static string GetFallbackUserAvatarUrl(int discriminator) => + $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png"; + + public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) => + avatarHash.StartsWith("a_", StringComparison.Ordinal) + ? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.gif?size={size}" + : $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.png?size={size}"; + + public static string GetStickerUrl(Snowflake stickerId, string format = "png") => + $"https://cdn.discordapp.com/stickers/{stickerId}.{format}"; +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/Emoji.cs b/DiscordChatExporter.Core/Discord/Data/Emoji.cs index 42f5d7f..2138155 100644 --- a/DiscordChatExporter.Core/Discord/Data/Emoji.cs +++ b/DiscordChatExporter.Core/Discord/Data/Emoji.cs @@ -1,6 +1,6 @@ using System; -using System.Linq; using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Utils.Extensions; using JsonExtensions.Reading; @@ -24,38 +24,15 @@ public partial record Emoji( public partial record Emoji { - private static string GetTwemojiId(string name) - { - var runes = name.GetRunes().ToArray(); - - // Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner. - // VS = 0xfe0f; ZWJ = 0x200d. - var filteredRunes = runes.Any(r => r.Value == 0x200d) - ? runes - : runes.Where(r => r.Value != 0xfe0f); - - return string.Join( - "-", - filteredRunes.Select(r => r.Value.ToString("x")) - ); - } - - private static string GetImageUrl(Snowflake id, bool isAnimated) => isAnimated - ? $"https://cdn.discordapp.com/emojis/{id}.gif" - : $"https://cdn.discordapp.com/emojis/{id}.png"; - - private static string GetImageUrl(string name) => - $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{GetTwemojiId(name)}.svg"; - public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated) { // Custom emoji if (id is not null) - return GetImageUrl(id.Value, isAnimated); + return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated); // Standard emoji if (!string.IsNullOrWhiteSpace(name)) - return GetImageUrl(name); + return ImageCdn.GetStandardEmojiUrl(name); // Either ID or name should be set throw new ApplicationException("Emoji has neither ID nor name set."); @@ -64,14 +41,16 @@ public partial record Emoji public static Emoji Parse(JsonElement json) { var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); - var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull(); + + // Names may be missing on custom emoji within reactions + var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji"; + var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false; var imageUrl = GetImageUrl(id, name, isAnimated); return new Emoji( id, - // Name may be missing if it's an emoji inside a reaction - name ?? "", + name, isAnimated, imageUrl ); diff --git a/DiscordChatExporter.Core/Utils/EmojiIndex.cs b/DiscordChatExporter.Core/Discord/Data/EmojiIndex.cs similarity index 100% rename from DiscordChatExporter.Core/Utils/EmojiIndex.cs rename to DiscordChatExporter.Core/Discord/Data/EmojiIndex.cs diff --git a/DiscordChatExporter.Core/Discord/Data/Guild.cs b/DiscordChatExporter.Core/Discord/Data/Guild.cs index 7c7c217..b80a7c8 100644 --- a/DiscordChatExporter.Core/Discord/Data/Guild.cs +++ b/DiscordChatExporter.Core/Discord/Data/Guild.cs @@ -1,5 +1,4 @@ -using System; -using System.Text.Json; +using System.Text.Json; using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Utils.Extensions; using JsonExtensions.Reading; @@ -9,32 +8,24 @@ namespace DiscordChatExporter.Core.Discord.Data; // https://discord.com/developers/docs/resources/guild#guild-object public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId { + // Direct messages are encapsulated within a special pseudo-guild for consistency public static Guild DirectMessages { get; } = new( Snowflake.Zero, "Direct Messages", - GetDefaultIconUrl() + ImageCdn.GetFallbackUserAvatarUrl(0) ); - private static string GetDefaultIconUrl() => - "https://cdn.discordapp.com/embed/avatars/0.png"; - - private static string GetIconUrl(Snowflake id, string iconHash) - { - var extension = iconHash.StartsWith("a_", StringComparison.Ordinal) - ? "gif" - : "png"; - - return $"https://cdn.discordapp.com/icons/{id}/{iconHash}.{extension}"; - } - public static Guild Parse(JsonElement json) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var name = json.GetProperty("name").GetNonNullString(); var iconUrl = - json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h)) ?? - GetDefaultIconUrl(); + json + .GetPropertyOrNull("icon")? + .GetNonWhiteSpaceStringOrNull()? + .Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? + ImageCdn.GetFallbackUserAvatarUrl(0); return new Guild(id, name, iconUrl); } diff --git a/DiscordChatExporter.Core/Discord/Data/Member.cs b/DiscordChatExporter.Core/Discord/Data/Member.cs index c389451..e65fd3c 100644 --- a/DiscordChatExporter.Core/Discord/Data/Member.cs +++ b/DiscordChatExporter.Core/Discord/Data/Member.cs @@ -11,7 +11,8 @@ namespace DiscordChatExporter.Core.Discord.Data; // https://discord.com/developers/docs/resources/guild#guild-member-object public partial record Member( User User, - string Nick, + string? Nick, + string? AvatarUrl, IReadOnlyList RoleIds) : IHasId { public Snowflake Id => User.Id; @@ -19,7 +20,7 @@ public partial record Member( public partial record Member { - public static Member Parse(JsonElement json) + public static Member Parse(JsonElement json, Snowflake? guildId = null) { var user = json.GetProperty("user").Pipe(User.Parse); var nick = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull(); @@ -31,9 +32,17 @@ public partial record Member .Select(Snowflake.Parse) .ToArray() ?? Array.Empty(); + var avatarUrl = guildId is not null + ? json + .GetPropertyOrNull("avatar")? + .GetNonWhiteSpaceStringOrNull()? + .Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h)) + : null; + return new Member( user, - nick ?? user.Name, + nick, + avatarUrl, roleIds ); } diff --git a/DiscordChatExporter.Core/Discord/Data/Sticker.cs b/DiscordChatExporter.Core/Discord/Data/Sticker.cs index aadd4d9..ebb81aa 100644 --- a/DiscordChatExporter.Core/Discord/Data/Sticker.cs +++ b/DiscordChatExporter.Core/Discord/Data/Sticker.cs @@ -1,4 +1,6 @@ -using System.Text.Json; +using System; +using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Utils.Extensions; using JsonExtensions.Reading; @@ -7,18 +9,19 @@ namespace DiscordChatExporter.Core.Discord.Data; // https://discord.com/developers/docs/resources/sticker#sticker-resource public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl) { - private static string GetSourceUrl(Snowflake id, StickerFormat format) - { - var extension = format == StickerFormat.Lottie ? "json" : "png"; - return $"https://discord.com/stickers/{id}.{extension}"; - } - public static Sticker Parse(JsonElement json) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var name = json.GetProperty("name").GetNonNullString(); var format = (StickerFormat)json.GetProperty("format_type").GetInt32(); - var sourceUrl = GetSourceUrl(id, format); + + var sourceUrl = ImageCdn.GetStickerUrl(id, format switch + { + StickerFormat.Png => "png", + StickerFormat.Apng => "png", + StickerFormat.Lottie => "json", + _ => throw new InvalidOperationException($"Unknown sticker format '{format}'.") + }); return new Sticker(id, name, format, sourceUrl); } diff --git a/DiscordChatExporter.Core/Discord/Data/StickerFormat.cs b/DiscordChatExporter.Core/Discord/Data/StickerFormat.cs index 12ffde3..e1e21bf 100644 --- a/DiscordChatExporter.Core/Discord/Data/StickerFormat.cs +++ b/DiscordChatExporter.Core/Discord/Data/StickerFormat.cs @@ -3,6 +3,6 @@ public enum StickerFormat { Png = 1, - PngAnimated = 2, + Apng = 2, Lottie = 3 } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/User.cs b/DiscordChatExporter.Core/Discord/Data/User.cs index ed249d5..4720080 100644 --- a/DiscordChatExporter.Core/Discord/Data/User.cs +++ b/DiscordChatExporter.Core/Discord/Data/User.cs @@ -1,5 +1,4 @@ -using System; -using System.Text.Json; +using System.Text.Json; using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Utils.Extensions; using JsonExtensions.Reading; @@ -21,18 +20,6 @@ public partial record User( public partial record User { - private static string GetDefaultAvatarUrl(int discriminator) => - $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png"; - - private static string GetAvatarUrl(Snowflake id, string avatarHash) - { - var extension = avatarHash.StartsWith("a_", StringComparison.Ordinal) - ? "gif" - : "png"; - - return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=512"; - } - public static User Parse(JsonElement json) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); @@ -41,8 +28,11 @@ public partial record User var name = json.GetProperty("username").GetNonNullString(); var avatarUrl = - json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetAvatarUrl(id, h)) ?? - GetDefaultAvatarUrl(discriminator); + json + .GetPropertyOrNull("avatar")? + .GetNonWhiteSpaceStringOrNull()? + .Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ?? + ImageCdn.GetFallbackUserAvatarUrl(discriminator); return new User(id, isBot, discriminator, name, avatarUrl); } diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index 40b71f3..5bc0a1a 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -260,7 +260,7 @@ public class DiscordClient return null; var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{memberId}", cancellationToken); - return response?.Pipe(Member.Parse); + return response?.Pipe(j => Member.Parse(j, guildId)); } public async ValueTask TryGetGuildInviteAsync( @@ -284,7 +284,7 @@ public class DiscordClient // Instead, we use an empty channel category as a fallback. catch (DiscordChatExporterException) { - return ChannelCategory.Unknown; + return new ChannelCategory(channelId, "Unknown Category", 0); } } diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index a3513ac..c3df597 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -50,7 +50,10 @@ internal class JsonMessageWriter : MessageWriter _writer.WriteString( "avatarUrl", - await Context.ResolveAssetUrlAsync(user.AvatarUrl, cancellationToken) + await Context.ResolveAssetUrlAsync( + Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl, + cancellationToken + ) ); _writer.WriteEndObject(); diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index 3d1cf02..00b2417 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -149,7 +149,7 @@ } // Avatar - Avatar + Avatar } else { @@ -172,7 +172,7 @@ ? message.ReferencedMessage.Author.Name : referencedUserMember?.Nick ?? message.ReferencedMessage.Author.Name; - Avatar + Avatar
@referencedUserNick
@@ -205,7 +205,7 @@ ? message.Interaction.User.Name : interactionUserMember?.Nick ?? message.Interaction.User.Name; - Avatar + Avatar
@interactionUserNick
used /@message.Interaction.Name @@ -337,14 +337,14 @@
- @(invite.Channel?.Name ?? "Unknown channel") + @(invite.Channel?.Name ?? "Unknown Channel")
@@ -621,7 +621,7 @@ @foreach (var sticker in message.Stickers) {
- @if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated) + @if (sticker.Format is StickerFormat.Png or StickerFormat.Apng) { Sticker }