Add support for replies to app interactions

Closes #569
pull/1003/head
Tyrrrz 2 years ago
parent 6620c6299c
commit d9a78d8e27

@ -56,4 +56,19 @@ public class HtmlReplySpecs
message.Text().Should().Contain("reply to attachment"); message.Text().Should().Contain("reply to attachment");
message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain("Click to see 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");
}
} }

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

@ -10,7 +10,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data; namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-object // https://discord.com/developers/docs/resources/channel#message-object
public record Message( public partial record Message(
Snowflake Id, Snowflake Id,
MessageKind Kind, MessageKind Kind,
MessageFlags Flags, MessageFlags Flags,
@ -26,7 +26,27 @@ public record Message(
IReadOnlyList<Reaction> Reactions, IReadOnlyList<Reaction> Reactions,
IReadOnlyList<User> MentionedUsers, IReadOnlyList<User> MentionedUsers,
MessageReference? Reference, MessageReference? Reference,
Message? ReferencedMessage) : IHasId Message? ReferencedMessage,
Interaction? Interaction) : IHasId
{
public bool IsReplyLike => Kind == MessageKind.Reply || Interaction is not null;
public IEnumerable<User> 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<Embed> NormalizeEmbeds(IReadOnlyList<Embed> embeds) private static IReadOnlyList<Embed> NormalizeEmbeds(IReadOnlyList<Embed> embeds)
{ {
@ -124,6 +144,7 @@ public record Message(
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse); var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse); var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
return new Message( return new Message(
id, id,
@ -141,7 +162,8 @@ public record Message(
reactions, reactions,
mentionedUsers, mentionedUsers,
messageReference, messageReference,
referencedMessage referencedMessage,
interaction
); );
} }
} }

@ -1,5 +1,4 @@
using System; using System;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
@ -33,8 +32,8 @@ public class ChannelExporter
progress, progress,
cancellationToken)) cancellationToken))
{ {
// Resolve members for the author and mentioned users // Resolve members for referenced users
foreach (var user in message.MentionedUsers.Prepend(message.Author)) foreach (var user in message.GetReferencedUsers())
await context.PopulateMemberAsync(user.Id, cancellationToken); await context.PopulateMemberAsync(user.Id, cancellationToken);
// Export the message // Export the message

@ -35,16 +35,23 @@ internal class JsonMessageWriter : MessageWriter
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken) ? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
: markdown; : markdown;
private async ValueTask WriteAttachmentAsync( private async ValueTask WriteUserAsync(
Attachment attachment, User user,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("id", attachment.Id.ToString()); _writer.WriteString("id", user.Id.ToString());
_writer.WriteString("url", await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)); _writer.WriteString("name", user.Name);
_writer.WriteString("fileName", attachment.FileName); _writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes); _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(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
@ -184,58 +191,6 @@ internal class JsonMessageWriter : MessageWriter
await _writer.FlushAsync(cancellationToken); 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) public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
{ {
// Root object (start) // Root object (start)
@ -310,26 +265,23 @@ internal class JsonMessageWriter : MessageWriter
} }
// Author // Author
_writer.WriteStartObject("author"); _writer.WritePropertyName("author");
_writer.WriteString("id", message.Author.Id.ToString()); await WriteUserAsync(message.Author, cancellationToken);
_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();
// Attachments // Attachments
_writer.WriteStartArray("attachments"); _writer.WriteStartArray("attachments");
foreach (var attachment in message.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(); _writer.WriteEndArray();
@ -345,7 +297,16 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartArray("stickers"); _writer.WriteStartArray("stickers");
foreach (var sticker in message.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(); _writer.WriteEndArray();
@ -353,15 +314,30 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartArray("reactions"); _writer.WriteStartArray("reactions");
foreach (var reaction in message.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(); _writer.WriteEndArray();
// Mentions // Mentions
_writer.WriteStartArray("mentions"); _writer.WriteStartArray("mentions");
foreach (var mention in message.MentionedUsers) foreach (var user in message.MentionedUsers)
await WriteMentionAsync(mention, cancellationToken); await WriteUserAsync(user, cancellationToken);
_writer.WriteEndArray(); _writer.WriteEndArray();
@ -375,6 +351,20 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteEndObject(); _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(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }

@ -31,30 +31,6 @@
Context.Request.ShouldFormatMarkdown Context.Request.ShouldFormatMarkdown
? await HtmlMarkdownVisitor.FormatAsync(Context, markdown, false, CancellationToken) ? await HtmlMarkdownVisitor.FormatAsync(Context, markdown, false, CancellationToken)
: markdown; : 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;
} }
<div class="chatlog__message-group"> <div class="chatlog__message-group">
@ -62,19 +38,17 @@
{ {
var isFirst = i == 0; 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;
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id"> <div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
<div class="chatlog__message"> <div class="chatlog__message">
@{/* System notification */} @{/* System notification */}
@if (message.Kind.IsSystemNotification()) @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;
<div class="chatlog__message-aside"> <div class="chatlog__message-aside">
<svg class="chatlog__system-notification-icon"> <svg class="chatlog__system-notification-icon">
@{ @{
@ -97,7 +71,7 @@
<div class="chatlog__message-primary"> <div class="chatlog__message-primary">
@{/* Author name */} @{/* Author name */}
<span class="chatlog__system-notification-author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span> <span class="chatlog__system-notification-author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorNick</span>
@{/* Space out the content */} @{/* Space out the content */}
<span> </span> <span> </span>
@ -169,7 +143,7 @@
@if (isFirst) @if (isFirst)
{ {
// Reply symbol // Reply symbol
if (message.Kind == MessageKind.Reply) if (message.IsReplyLike)
{ {
<div class="chatlog__reply-symbol"></div> <div class="chatlog__reply-symbol"></div>
} }
@ -186,12 +160,18 @@
<div class="chatlog__message-primary"> <div class="chatlog__message-primary">
@if (isFirst) @if (isFirst)
{ {
// Reply // Message referenced by the reply
if (message.Kind == MessageKind.Reply && message.Reference is not null) if (message.IsReplyLike)
{ {
<div class="chatlog__reply"> <div class="chatlog__reply">
@if (message.ReferencedMessage is not null) @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;
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(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">
@ -217,6 +197,20 @@
} }
</div> </div>
} }
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;
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(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
</div>
}
else else
{ {
<div class="chatlog__reply-unknown"> <div class="chatlog__reply-unknown">
@ -229,7 +223,7 @@
// Header // Header
<div class="chatlog__header"> <div class="chatlog__header">
@{/* Author name */} @{/* Author name */}
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span> <span class="chatlog__author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorNick</span>
@{/* Bot tag */} @{/* Bot tag */}
@if (message.Author.IsBot) @if (message.Author.IsBot)

Loading…
Cancel
Save