Add support for member-level avatars

pull/1003/head
Tyrrrz 1 year ago
parent c2c35cf3a3
commit 55209a0517

@ -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,

@ -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, "<unknown category>", 0);
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
{
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.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 ?? "<unknown emoji>",
name,
isAnimated,
imageUrl
);

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

@ -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<Snowflake> 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<Snowflake>();
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
);
}

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

@ -3,6 +3,6 @@
public enum StickerFormat
{
Png = 1,
PngAnimated = 2,
Apng = 2,
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.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);
}

@ -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<Invite?> 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);
}
}

@ -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();

@ -149,7 +149,7 @@
}
// 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
{
@ -172,7 +172,7 @@
? 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-content">
<span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@ -205,7 +205,7 @@
? 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-content">
used /@message.Interaction.Name
@ -337,14 +337,14 @@
<div class="chatlog__embed-invite-info">
<div class="chatlog__embed-invite-guild-name">
<a href="https://discord.gg/@invite.Code">
@(invite.Guild?.Name ?? "Unknown guild")
@(invite.Guild?.Name ?? "Unknown Guild")
</a>
</div>
<div class="chatlog__embed-invite-channel-name">
<svg class="chatlog__embed-invite-channel-icon">
<use href="#channel-icon"></use>
</svg>
<span> @(invite.Channel?.Name ?? "Unknown channel")</span>
<span> @(invite.Channel?.Name ?? "Unknown Channel")</span>
</div>
</div>
</div>
@ -621,7 +621,7 @@
@foreach (var sticker in message.Stickers)
{
<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">
}

Loading…
Cancel
Save