Add support for member-level avatars

pull/1003/head
Tyrrrz 2 years ago
parent c2c35cf3a3
commit 55209a0517

@ -1,5 +1,4 @@
using System; using System.Linq;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@ -37,20 +36,16 @@ public partial record Channel
null null
); );
private static string GetIconUrl(Snowflake id, string iconHash) public static Channel Parse(JsonElement json, ChannelCategory? categoryHint = null, int? positionHint = null)
{
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)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var kind = (ChannelKind)json.GetProperty("type").GetInt32(); 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 = var name =
// Guild channel // Guild channel
@ -70,8 +65,11 @@ public partial record Channel
positionHint ?? positionHint ??
json.GetPropertyOrNull("position")?.GetInt32OrNull(); json.GetPropertyOrNull("position")?.GetInt32OrNull();
// Only available on group DMs // Icons can only be set for group DM channels
var iconUrl = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h)); var iconUrl = json
.GetPropertyOrNull("icon")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetChannelIconUrl(id, h));
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull(); var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
@ -83,8 +81,8 @@ public partial record Channel
return new Channel( return new Channel(
id, id,
kind, kind,
guildId ?? Guild.DirectMessages.Id, guildId,
category ?? GetFallbackCategory(kind), category,
name, name,
position, position,
iconUrl, iconUrl,

@ -7,8 +7,6 @@ namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
{ {
public static ChannelCategory Unknown { get; } = new(Snowflake.Zero, "<unknown category>", 0);
public static ChannelCategory Parse(JsonElement json, int? positionHint = null) public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);

@ -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}";
}

@ -1,6 +1,6 @@
using System; using System;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@ -24,38 +24,15 @@ public partial record Emoji(
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) public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{ {
// Custom emoji // Custom emoji
if (id is not null) if (id is not null)
return GetImageUrl(id.Value, isAnimated); return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
// Standard emoji // Standard emoji
if (!string.IsNullOrWhiteSpace(name)) if (!string.IsNullOrWhiteSpace(name))
return GetImageUrl(name); return ImageCdn.GetStandardEmojiUrl(name);
// Either ID or name should be set // Either ID or name should be set
throw new ApplicationException("Emoji has neither ID nor name 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) public static Emoji Parse(JsonElement json)
{ {
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); 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 isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
var imageUrl = GetImageUrl(id, name, isAnimated); var imageUrl = GetImageUrl(id, name, isAnimated);
return new Emoji( return new Emoji(
id, id,
// Name may be missing if it's an emoji inside a reaction name,
name ?? "<unknown emoji>",
isAnimated, isAnimated,
imageUrl imageUrl
); );

@ -1,5 +1,4 @@
using System; using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@ -9,32 +8,24 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/guild#guild-object // https://discord.com/developers/docs/resources/guild#guild-object
public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId 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( public static Guild DirectMessages { get; } = new(
Snowflake.Zero, Snowflake.Zero,
"Direct Messages", "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) public static Guild Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonNullString(); var name = json.GetProperty("name").GetNonNullString();
var iconUrl = var iconUrl =
json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h)) ?? json
GetDefaultIconUrl(); .GetPropertyOrNull("icon")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl(0);
return new Guild(id, name, iconUrl); return new Guild(id, name, iconUrl);
} }

@ -11,7 +11,8 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/guild#guild-member-object // https://discord.com/developers/docs/resources/guild#guild-member-object
public partial record Member( public partial record Member(
User User, User User,
string Nick, string? Nick,
string? AvatarUrl,
IReadOnlyList<Snowflake> RoleIds) : IHasId IReadOnlyList<Snowflake> RoleIds) : IHasId
{ {
public Snowflake Id => User.Id; public Snowflake Id => User.Id;
@ -19,7 +20,7 @@ public partial record Member(
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 user = json.GetProperty("user").Pipe(User.Parse);
var nick = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull(); var nick = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull();
@ -31,9 +32,17 @@ public partial record Member
.Select(Snowflake.Parse) .Select(Snowflake.Parse)
.ToArray() ?? Array.Empty<Snowflake>(); .ToArray() ?? Array.Empty<Snowflake>();
var avatarUrl = guildId is not null
? json
.GetPropertyOrNull("avatar")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h))
: null;
return new Member( return new Member(
user, user,
nick ?? user.Name, nick,
avatarUrl,
roleIds roleIds
); );
} }

@ -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 DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@ -7,18 +9,19 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/sticker#sticker-resource // https://discord.com/developers/docs/resources/sticker#sticker-resource
public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl) 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) public static Sticker Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonNullString(); var name = json.GetProperty("name").GetNonNullString();
var format = (StickerFormat)json.GetProperty("format_type").GetInt32(); 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); return new Sticker(id, name, format, sourceUrl);
} }

@ -3,6 +3,6 @@
public enum StickerFormat public enum StickerFormat
{ {
Png = 1, Png = 1,
PngAnimated = 2, Apng = 2,
Lottie = 3 Lottie = 3
} }

@ -1,5 +1,4 @@
using System; using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@ -21,18 +20,6 @@ public partial record User(
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) public static User Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
@ -41,8 +28,11 @@ public partial record User
var name = json.GetProperty("username").GetNonNullString(); var name = json.GetProperty("username").GetNonNullString();
var avatarUrl = var avatarUrl =
json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetAvatarUrl(id, h)) ?? json
GetDefaultAvatarUrl(discriminator); .GetPropertyOrNull("avatar")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl(discriminator);
return new User(id, isBot, discriminator, name, avatarUrl); return new User(id, isBot, discriminator, name, avatarUrl);
} }

@ -260,7 +260,7 @@ public class DiscordClient
return null; return null;
var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{memberId}", cancellationToken); 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<Invite?> TryGetGuildInviteAsync( public async ValueTask<Invite?> TryGetGuildInviteAsync(
@ -284,7 +284,7 @@ public class DiscordClient
// Instead, we use an empty channel category as a fallback. // Instead, we use an empty channel category as a fallback.
catch (DiscordChatExporterException) catch (DiscordChatExporterException)
{ {
return ChannelCategory.Unknown; return new ChannelCategory(channelId, "Unknown Category", 0);
} }
} }

@ -50,7 +50,10 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString( _writer.WriteString(
"avatarUrl", "avatarUrl",
await Context.ResolveAssetUrlAsync(user.AvatarUrl, cancellationToken) await Context.ResolveAssetUrlAsync(
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
cancellationToken
)
); );
_writer.WriteEndObject(); _writer.WriteEndObject();

@ -149,7 +149,7 @@
} }
// Avatar // Avatar
<img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(authorMember?.AvatarUrl ?? message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
} }
else else
{ {
@ -172,7 +172,7 @@
? message.ReferencedMessage.Author.Name ? message.ReferencedMessage.Author.Name
: referencedUserMember?.Nick ?? message.ReferencedMessage.Author.Name; : referencedUserMember?.Nick ?? message.ReferencedMessage.Author.Name;
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(referencedUserMember?.AvatarUrl ?? message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reply-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div> <div class="chatlog__reply-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
<div class="chatlog__reply-content"> <div class="chatlog__reply-content">
<span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')"> <span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@ -205,7 +205,7 @@
? message.Interaction.User.Name ? message.Interaction.User.Name
: interactionUserMember?.Nick ?? message.Interaction.User.Name; : interactionUserMember?.Nick ?? message.Interaction.User.Name;
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(message.Interaction.User.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(interactionUserMember?.AvatarUrl ?? message.Interaction.User.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reply-author" style="@(interactionUserColor is not null ? $"color: rgb({interactionUserColor.Value.R}, {interactionUserColor.Value.G}, {interactionUserColor.Value.B})" : null)" title="@message.Interaction.User.FullName">@interactionUserNick</div> <div class="chatlog__reply-author" style="@(interactionUserColor is not null ? $"color: rgb({interactionUserColor.Value.R}, {interactionUserColor.Value.G}, {interactionUserColor.Value.B})" : null)" title="@message.Interaction.User.FullName">@interactionUserNick</div>
<div class="chatlog__reply-content"> <div class="chatlog__reply-content">
used /@message.Interaction.Name used /@message.Interaction.Name
@ -337,14 +337,14 @@
<div class="chatlog__embed-invite-info"> <div class="chatlog__embed-invite-info">
<div class="chatlog__embed-invite-guild-name"> <div class="chatlog__embed-invite-guild-name">
<a href="https://discord.gg/@invite.Code"> <a href="https://discord.gg/@invite.Code">
@(invite.Guild?.Name ?? "Unknown guild") @(invite.Guild?.Name ?? "Unknown Guild")
</a> </a>
</div> </div>
<div class="chatlog__embed-invite-channel-name"> <div class="chatlog__embed-invite-channel-name">
<svg class="chatlog__embed-invite-channel-icon"> <svg class="chatlog__embed-invite-channel-icon">
<use href="#channel-icon"></use> <use href="#channel-icon"></use>
</svg> </svg>
<span> @(invite.Channel?.Name ?? "Unknown channel")</span> <span> @(invite.Channel?.Name ?? "Unknown Channel")</span>
</div> </div>
</div> </div>
</div> </div>
@ -621,7 +621,7 @@
@foreach (var sticker in message.Stickers) @foreach (var sticker in message.Stickers)
{ {
<div class="chatlog__sticker" title="@sticker.Name"> <div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated) @if (sticker.Format is StickerFormat.Png or StickerFormat.Apng)
{ {
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker"> <img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
} }

Loading…
Cancel
Save