Skip to content

Commit

Permalink
Include inline emoji in JSON export (#1311)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tyrrrz authored Nov 6, 2024
1 parent 9c15baf commit 789e5af
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 87 deletions.
2 changes: 2 additions & 0 deletions DiscordChatExporter.Cli.Tests/Infra/ChannelIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public static class ChannelIds

public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");

public static Snowflake EmojiTestCases { get; } = Snowflake.Parse("866768438290415636");

public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");

public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
Expand Down
69 changes: 69 additions & 0 deletions DiscordChatExporter.Cli.Tests/Specs/JsonEmojiSpecs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class JsonEmojiSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_inline_emoji_and_have_them_listed_separately()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.EmojiTestCases,
Snowflake.Parse("866768521052553216")
);

// Assert
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
inlineEmojis.Should().HaveCount(4);

inlineEmojis[0].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[0].GetProperty("name").GetString().Should().Be("🙂");
inlineEmojis[0].GetProperty("code").GetString().Should().Be("slight_smile");
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();

inlineEmojis[1].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[1].GetProperty("name").GetString().Should().Be("😦");
inlineEmojis[1].GetProperty("code").GetString().Should().Be("frowning");
inlineEmojis[1].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[1].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();

inlineEmojis[2].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[2].GetProperty("name").GetString().Should().Be("😔");
inlineEmojis[2].GetProperty("code").GetString().Should().Be("pensive");
inlineEmojis[2].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[2].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();

inlineEmojis[3].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[3].GetProperty("name").GetString().Should().Be("😂");
inlineEmojis[3].GetProperty("code").GetString().Should().Be("joy");
inlineEmojis[3].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[3].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
}

[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_custom_inline_emoji_and_have_them_listed_separately()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.EmojiTestCases,
Snowflake.Parse("1299804867447230594")
);

// Assert
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
inlineEmojis.Should().HaveCount(1);

inlineEmojis[0].GetProperty("id").GetString().Should().Be("754441880066064584");
inlineEmojis[0].GetProperty("name").GetString().Should().Be("lemon_blush");
inlineEmojis[0].GetProperty("code").GetString().Should().Be("lemon_blush");
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
}
}
31 changes: 11 additions & 20 deletions DiscordChatExporter.Core/Discord/Data/Emoji.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
Expand All @@ -13,29 +12,22 @@ public partial record Emoji(
Snowflake? Id,
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
string Name,
bool IsAnimated,
string ImageUrl
bool IsAnimated
)
{
public bool IsCustomEmoji { get; } = Id is not null;

// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;

public string ImageUrl { get; } =
Id is not null
? ImageCdn.GetCustomEmojiUrl(Id.Value, IsAnimated)
: ImageCdn.GetStandardEmojiUrl(Name);
}

public partial record Emoji
{
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{
// Custom emoji
if (id is not null)
return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);

// Standard emoji
if (!string.IsNullOrWhiteSpace(name))
return ImageCdn.GetStandardEmojiUrl(name);

throw new InvalidOperationException("Either the emoji ID or name should be provided.");
}

public static Emoji Parse(JsonElement json)
{
var id = json.GetPropertyOrNull("id")
Expand All @@ -47,8 +39,7 @@ public static Emoji Parse(JsonElement json)
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";

var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
var imageUrl = GetImageUrl(id, name, isAnimated);

return new Emoji(id, name, isAnimated, imageUrl);
return new Emoji(id, name, isAnimated);
}
}
4 changes: 1 addition & 3 deletions DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
Expand Down Expand Up @@ -210,7 +209,6 @@ protected override async ValueTask VisitEmojiAsync(
CancellationToken cancellationToken = default
)
{
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = isJumbo ? "chatlog__emoji--large" : "";

buffer.Append(
Expand All @@ -221,7 +219,7 @@ protected override async ValueTask VisitEmojiAsync(
class="chatlog__emoji {jumboClass}"
alt="{emoji.Name}"
title="{emoji.Code}"
src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
src="{await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)}">
"""
);
}
Expand Down
118 changes: 79 additions & 39 deletions DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Writing;

Expand Down Expand Up @@ -37,22 +39,31 @@ private async ValueTask<string> FormatMarkdownAsync(
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
: markdown;

private async ValueTask WriteUserAsync(User user, CancellationToken cancellationToken = default)
private async ValueTask WriteUserAsync(
User user,
bool includeRoles = true,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();

_writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted);

_writer.WriteString(
"nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);

_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteBoolean("isBot", user.IsBot);

_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
if (includeRoles)
{
_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
}

_writer.WriteString(
"avatarUrl",
Expand All @@ -66,6 +77,26 @@ await Context.ResolveAssetUrlAsync(
await _writer.FlushAsync(cancellationToken);
}

private async ValueTask WriteEmojiAsync(
Emoji emoji,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();

_writer.WriteString("id", emoji.Id.ToString());
_writer.WriteString("name", emoji.Name);
_writer.WriteString("code", emoji.Code);
_writer.WriteBoolean("isAnimated", emoji.IsAnimated);
_writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)
);

_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}

private async ValueTask WriteRolesAsync(
IReadOnlyList<Role> roles,
CancellationToken cancellationToken = default
Expand Down Expand Up @@ -273,6 +304,26 @@ await FormatMarkdownAsync(embed.Description ?? "", cancellationToken)

_writer.WriteEndArray();

// Inline emoji
_writer.WriteStartArray("inlineEmojis");

if (!string.IsNullOrWhiteSpace(embed.Description))
{
foreach (
var emoji in MarkdownParser
.ExtractEmojis(embed.Description)
.DistinctBy(e => e.Name, StringComparer.Ordinal)
)
{
await WriteEmojiAsync(
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
cancellationToken
);
}
}

_writer.WriteEndArray();

_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
Expand Down Expand Up @@ -373,7 +424,7 @@ await FormatMarkdownAsync(message.Content, cancellationToken)

// Author
_writer.WritePropertyName("author");
await WriteUserAsync(message.Author, cancellationToken);
await WriteUserAsync(message.Author, true, cancellationToken);

// Attachments
_writer.WriteStartArray("attachments");
Expand Down Expand Up @@ -431,20 +482,14 @@ await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
_writer.WriteStartObject();

// Emoji
_writer.WriteStartObject("emoji");
_writer.WriteString("id", reaction.Emoji.Id.ToString());
_writer.WriteString("name", reaction.Emoji.Name);
_writer.WriteString("code", reaction.Emoji.Code);
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
_writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken)
);
_writer.WriteEndObject();
_writer.WritePropertyName("emoji");
await WriteEmojiAsync(reaction.Emoji, cancellationToken);

_writer.WriteNumber("count", reaction.Count);

// Reaction authors
_writer.WriteStartArray("users");

await foreach (
var user in Context.Discord.GetMessageReactionsAsync(
Context.Request.Channel.Id,
Expand All @@ -454,28 +499,7 @@ var user in Context.Discord.GetMessageReactionsAsync(
)
)
{
_writer.WriteStartObject();

// Write limited user information without color and roles,
// so we can avoid fetching guild member information for each user.
_writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString(
"nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);
_writer.WriteBoolean("isBot", user.IsBot);

_writer.WriteString(
"avatarUrl",
await Context.ResolveAssetUrlAsync(
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
cancellationToken
)
);

_writer.WriteEndObject();
await WriteUserAsync(user, false, cancellationToken);
}

_writer.WriteEndArray();
Expand All @@ -487,9 +511,8 @@ await Context.ResolveAssetUrlAsync(

// Mentions
_writer.WriteStartArray("mentions");

foreach (var user in message.MentionedUsers)
await WriteUserAsync(user, cancellationToken);
await WriteUserAsync(user, true, cancellationToken);

_writer.WriteEndArray();

Expand All @@ -512,11 +535,28 @@ await Context.ResolveAssetUrlAsync(
_writer.WriteString("name", message.Interaction.Name);

_writer.WritePropertyName("user");
await WriteUserAsync(message.Interaction.User, cancellationToken);
await WriteUserAsync(message.Interaction.User, true, cancellationToken);

_writer.WriteEndObject();
}

// Inline emoji
_writer.WriteStartArray("inlineEmojis");

foreach (
var emoji in MarkdownParser
.ExtractEmojis(message.Content)
.DistinctBy(e => e.Name, StringComparer.Ordinal)
)
{
await WriteEmojiAsync(
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
cancellationToken
);
}

_writer.WriteEndArray();

_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
Expand Down
16 changes: 11 additions & 5 deletions DiscordChatExporter.Core/Markdown/EmojiNode.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Discord.Data;

namespace DiscordChatExporter.Core.Markdown;

Expand All @@ -11,11 +11,17 @@ internal record EmojiNode(
bool IsAnimated
) : MarkdownNode
{
public bool IsCustomEmoji => Id is not null;

// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
// This coupling is unsound from the domain-design perspective, but it helps us reuse
// some code for now. We can refactor this later, if the coupling becomes a problem.
private readonly Emoji _emoji = new(Id, Name, IsAnimated);

public EmojiNode(string name)
: this(null, name, false) { }

public bool IsCustomEmoji => _emoji.IsCustomEmoji;

// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => _emoji.Code;

public string ImageUrl => _emoji.ImageUrl;
}
Loading

0 comments on commit 789e5af

Please sign in to comment.