diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlReplySpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlReplySpecs.cs index 8fcbb942..d2051c4b 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlReplySpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlReplySpecs.cs @@ -56,4 +56,19 @@ public class HtmlReplySpecs message.Text().Should().Contain("reply to attachment"); message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain("Click to see attachment"); } + + [Fact] + public async Task Message_with_a_reply_to_an_interaction_is_rendered_correctly() + { + // https://github.com/Tyrrrz/DiscordChatExporter/issues/569 + + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.ReplyTestCases, + Snowflake.Parse("1075152916417085492") + ); + + // Assert + message.Text().Should().Contain("used /poll"); + } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/Interaction.cs b/DiscordChatExporter.Core/Discord/Data/Interaction.cs new file mode 100644 index 00000000..d765cf3c --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Interaction.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using DiscordChatExporter.Core.Utils.Extensions; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data; + +// https://discord.com/developers/docs/interactions/receiving-and-responding#message-interaction-object +public record Interaction(Snowflake Id, string Name, User User) +{ + public static Interaction Parse(JsonElement json) + { + var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); + var name = json.GetProperty("name").GetNonWhiteSpaceString(); + var user = json.GetProperty("user").Pipe(User.Parse); + + return new Interaction(id, name, user); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 19e6f99e..a8b004c4 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -10,7 +10,7 @@ using JsonExtensions.Reading; namespace DiscordChatExporter.Core.Discord.Data; // https://discord.com/developers/docs/resources/channel#message-object -public record Message( +public partial record Message( Snowflake Id, MessageKind Kind, MessageFlags Flags, @@ -26,7 +26,27 @@ public record Message( IReadOnlyList Reactions, IReadOnlyList MentionedUsers, MessageReference? Reference, - Message? ReferencedMessage) : IHasId + Message? ReferencedMessage, + Interaction? Interaction) : IHasId +{ + public bool IsReplyLike => Kind == MessageKind.Reply || Interaction is not null; + + public IEnumerable GetReferencedUsers() + { + yield return Author; + + foreach (var user in MentionedUsers) + yield return user; + + if (ReferencedMessage is not null) + yield return ReferencedMessage.Author; + + if (Interaction is not null) + yield return Interaction.User; + } +} + +public partial record Message { private static IReadOnlyList NormalizeEmbeds(IReadOnlyList embeds) { @@ -124,6 +144,7 @@ public record Message( var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse); var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse); + var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse); return new Message( id, @@ -141,7 +162,8 @@ public record Message( reactions, mentionedUsers, messageReference, - referencedMessage + referencedMessage, + interaction ); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 0ec51143..527445b0 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord; @@ -33,8 +32,8 @@ public class ChannelExporter progress, cancellationToken)) { - // Resolve members for the author and mentioned users - foreach (var user in message.MentionedUsers.Prepend(message.Author)) + // Resolve members for referenced users + foreach (var user in message.GetReferencedUsers()) await context.PopulateMemberAsync(user.Id, cancellationToken); // Export the message diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index 4acd6e97..a3513acd 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -35,16 +35,23 @@ internal class JsonMessageWriter : MessageWriter ? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken) : markdown; - private async ValueTask WriteAttachmentAsync( - Attachment attachment, + private async ValueTask WriteUserAsync( + User user, CancellationToken cancellationToken = default) { _writer.WriteStartObject(); - _writer.WriteString("id", attachment.Id.ToString()); - _writer.WriteString("url", await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)); - _writer.WriteString("fileName", attachment.FileName); - _writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes); + _writer.WriteString("id", user.Id.ToString()); + _writer.WriteString("name", user.Name); + _writer.WriteString("discriminator", user.DiscriminatorFormatted); + _writer.WriteString("nickname", Context.TryGetMember(user.Id)?.Nick ?? user.Name); + _writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex()); + _writer.WriteBoolean("isBot", user.IsBot); + + _writer.WriteString( + "avatarUrl", + await Context.ResolveAssetUrlAsync(user.AvatarUrl, cancellationToken) + ); _writer.WriteEndObject(); await _writer.FlushAsync(cancellationToken); @@ -184,58 +191,6 @@ internal class JsonMessageWriter : MessageWriter await _writer.FlushAsync(cancellationToken); } - private async ValueTask WriteStickerAsync( - Sticker sticker, - CancellationToken cancellationToken = default) - { - _writer.WriteStartObject(); - - _writer.WriteString("id", sticker.Id.ToString()); - _writer.WriteString("name", sticker.Name); - _writer.WriteString("format", sticker.Format.ToString()); - _writer.WriteString("sourceUrl", await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)); - - _writer.WriteEndObject(); - await _writer.FlushAsync(cancellationToken); - } - - private async ValueTask WriteReactionAsync( - Reaction reaction, - CancellationToken cancellationToken = default) - { - _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.WriteNumber("count", reaction.Count); - - _writer.WriteEndObject(); - await _writer.FlushAsync(cancellationToken); - } - - private async ValueTask WriteMentionAsync( - User mentionedUser, - CancellationToken cancellationToken = default) - { - _writer.WriteStartObject(); - - _writer.WriteString("id", mentionedUser.Id.ToString()); - _writer.WriteString("name", mentionedUser.Name); - _writer.WriteString("discriminator", mentionedUser.DiscriminatorFormatted); - _writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name); - _writer.WriteBoolean("isBot", mentionedUser.IsBot); - - _writer.WriteEndObject(); - await _writer.FlushAsync(cancellationToken); - } - public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) { // Root object (start) @@ -310,26 +265,23 @@ internal class JsonMessageWriter : MessageWriter } // Author - _writer.WriteStartObject("author"); - _writer.WriteString("id", message.Author.Id.ToString()); - _writer.WriteString("name", message.Author.Name); - _writer.WriteString("discriminator", message.Author.DiscriminatorFormatted); - _writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name); - _writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex()); - _writer.WriteBoolean("isBot", message.Author.IsBot); - - _writer.WriteString( - "avatarUrl", - await Context.ResolveAssetUrlAsync(message.Author.AvatarUrl, cancellationToken) - ); - - _writer.WriteEndObject(); + _writer.WritePropertyName("author"); + await WriteUserAsync(message.Author, cancellationToken); // Attachments _writer.WriteStartArray("attachments"); foreach (var attachment in message.Attachments) - await WriteAttachmentAsync(attachment, cancellationToken); + { + _writer.WriteStartObject(); + + _writer.WriteString("id", attachment.Id.ToString()); + _writer.WriteString("url", await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)); + _writer.WriteString("fileName", attachment.FileName); + _writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes); + + _writer.WriteEndObject(); + } _writer.WriteEndArray(); @@ -345,7 +297,16 @@ internal class JsonMessageWriter : MessageWriter _writer.WriteStartArray("stickers"); foreach (var sticker in message.Stickers) - await WriteStickerAsync(sticker, cancellationToken); + { + _writer.WriteStartObject(); + + _writer.WriteString("id", sticker.Id.ToString()); + _writer.WriteString("name", sticker.Name); + _writer.WriteString("format", sticker.Format.ToString()); + _writer.WriteString("sourceUrl", await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)); + + _writer.WriteEndObject(); + } _writer.WriteEndArray(); @@ -353,15 +314,30 @@ internal class JsonMessageWriter : MessageWriter _writer.WriteStartArray("reactions"); foreach (var reaction in message.Reactions) - await WriteReactionAsync(reaction, 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.WriteNumber("count", reaction.Count); + + _writer.WriteEndObject(); + } _writer.WriteEndArray(); // Mentions _writer.WriteStartArray("mentions"); - foreach (var mention in message.MentionedUsers) - await WriteMentionAsync(mention, cancellationToken); + foreach (var user in message.MentionedUsers) + await WriteUserAsync(user, cancellationToken); _writer.WriteEndArray(); @@ -375,6 +351,20 @@ internal class JsonMessageWriter : MessageWriter _writer.WriteEndObject(); } + // Interaction + if (message.Interaction is not null) + { + _writer.WriteStartObject("interaction"); + + _writer.WriteString("id", message.Interaction.Id.ToString()); + _writer.WriteString("name", message.Interaction.Name); + + _writer.WritePropertyName("user"); + await WriteUserAsync(message.Interaction.User, cancellationToken); + + _writer.WriteEndObject(); + } + _writer.WriteEndObject(); await _writer.FlushAsync(cancellationToken); } diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index 48ba684c..209c4dc3 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -31,30 +31,6 @@ Context.Request.ShouldFormatMarkdown ? await HtmlMarkdownVisitor.FormatAsync(Context, markdown, false, CancellationToken) : markdown; - - var firstMessage = Messages.First(); - - var userMember = Context.TryGetMember(firstMessage.Author.Id); - - var userColor = Context.TryGetUserColor(firstMessage.Author.Id); - - var userNick = firstMessage.Author.IsBot - ? firstMessage.Author.Name - : userMember?.Nick ?? firstMessage.Author.Name; - - var referencedUserMember = firstMessage.ReferencedMessage is not null - ? Context.TryGetMember(firstMessage.ReferencedMessage.Author.Id) - : null; - - var referencedUserColor = firstMessage.ReferencedMessage is not null - ? Context.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id) - : null; - - var referencedUserNick = firstMessage.ReferencedMessage is not null - ? firstMessage.ReferencedMessage.Author.IsBot - ? firstMessage.ReferencedMessage.Author.Name - : referencedUserMember?.Nick ?? firstMessage.ReferencedMessage.Author.Name - : null; }
@@ -62,19 +38,17 @@ { var isFirst = i == 0; + var authorMember = Context.TryGetMember(message.Author.Id); + var authorColor = Context.TryGetUserColor(message.Author.Id); + var authorNick = message.Author.IsBot + ? message.Author.Name + : authorMember?.Nick ?? message.Author.Name; +
@{/* System notification */} @if (message.Kind.IsSystemNotification()) { - // System notifications are grouped even if the message author is different. - // That's why we have to update the user values with the author of the current message. - userMember = Context.TryGetMember(message.Author.Id); - userColor = Context.TryGetUserColor(message.Author.Id); - userNick = message.Author.IsBot - ? message.Author.Name - : userMember?.Nick ?? message.Author.Name; -
@{ @@ -97,7 +71,7 @@
@{/* Author name */} - @userNick + @authorNick @{/* Space out the content */} @@ -169,7 +143,7 @@ @if (isFirst) { // Reply symbol - if (message.Kind == MessageKind.Reply) + if (message.IsReplyLike) {
} @@ -186,12 +160,18 @@
@if (isFirst) { - // Reply - if (message.Kind == MessageKind.Reply && message.Reference is not null) + // Message referenced by the reply + if (message.IsReplyLike) {
@if (message.ReferencedMessage is not null) { + var referencedUserMember = Context.TryGetMember(message.ReferencedMessage.Author.Id); + var referencedUserColor = Context.TryGetUserColor(message.ReferencedMessage.Author.Id); + var referencedUserNick = message.ReferencedMessage.Author.IsBot + ? message.ReferencedMessage.Author.Name + : referencedUserMember?.Nick ?? message.ReferencedMessage.Author.Name; + Avatar
@referencedUserNick
@@ -217,6 +197,20 @@ }
} + else if (message.Interaction is not null) + { + var interactionUserMember = Context.TryGetMember(message.Interaction.User.Id); + var interactionUserColor = Context.TryGetUserColor(message.Interaction.User.Id); + var interactionUserNick = message.Interaction.User.IsBot + ? message.Interaction.User.Name + : interactionUserMember?.Nick ?? message.Interaction.User.Name; + + Avatar +
@interactionUserNick
+
+ used /@message.Interaction.Name +
+ } else {
@@ -229,7 +223,7 @@ // Header
@{/* Author name */} - @userNick + @authorNick @{/* Bot tag */} @if (message.Author.IsBot)