From b63dabdd6b76e7e1218471998fb66bc091cc1d8e Mon Sep 17 00:00:00 2001 From: farley128 Date: Wed, 24 Dec 2025 05:23:42 -0500 Subject: [PATCH] Implement exporting for forwarded messages --- .../Discord/Data/Message.cs | 16 +++- .../Discord/Data/MessageReference.cs | 13 ++- .../Discord/Data/MessageReferenceKind.cs | 8 ++ .../Discord/Data/MessageSnapshot.cs | 67 +++++++++++++ .../Exporting/CsvMessageWriter.cs | 16 +++- .../Exporting/JsonMessageWriter.cs | 64 +++++++++++++ .../Exporting/MessageGroupTemplate.cshtml | 93 +++++++++++++++++++ .../Exporting/PlainTextMessageWriter.cs | 37 +++++++- .../Exporting/PreambleTemplate.cshtml | 57 ++++++++++++ 9 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs create mode 100644 DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 56b3b87b..2c570591 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -27,7 +27,8 @@ public partial record Message( IReadOnlyList MentionedUsers, MessageReference? Reference, Message? ReferencedMessage, - Interaction? Interaction + Interaction? Interaction, + MessageSnapshot? ForwardedMessage ) : IHasId { public bool IsSystemNotification { get; } = @@ -35,6 +36,9 @@ public partial record Message( public bool IsReply { get; } = Kind == MessageKind.Reply; + // A message is a forward if its reference type is Forward + public bool IsForward { get; } = Reference?.Kind == MessageReferenceKind.Forward; + // App interactions are rendered as replies in the Discord client, but they are not actually replies public bool IsReplyLike => IsReply || Interaction is not null; @@ -171,6 +175,13 @@ public partial record Message var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse); var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse); + // Parse message snapshots for forwarded messages + // Currently Discord only supports 1 snapshot per forward + var forwardedMessage = json.GetPropertyOrNull("message_snapshots") + ?.EnumerateArrayOrNull() + ?.Select(MessageSnapshot.Parse) + .FirstOrDefault(); + return new Message( id, kind, @@ -188,7 +199,8 @@ public partial record Message mentionedUsers, messageReference, referencedMessage, - interaction + interaction, + forwardedMessage ); } } diff --git a/DiscordChatExporter.Core/Discord/Data/MessageReference.cs b/DiscordChatExporter.Core/Discord/Data/MessageReference.cs index 6028e942..af024f4c 100644 --- a/DiscordChatExporter.Core/Discord/Data/MessageReference.cs +++ b/DiscordChatExporter.Core/Discord/Data/MessageReference.cs @@ -5,7 +5,12 @@ using JsonExtensions.Reading; namespace DiscordChatExporter.Core.Discord.Data; // https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure -public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId) +public record MessageReference( + Snowflake? MessageId, + Snowflake? ChannelId, + Snowflake? GuildId, + MessageReferenceKind Kind +) { public static MessageReference Parse(JsonElement json) { @@ -21,6 +26,10 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf ?.GetNonWhiteSpaceStringOrNull() ?.Pipe(Snowflake.Parse); - return new MessageReference(messageId, channelId, guildId); + var kind = + json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t) + ?? MessageReferenceKind.Default; + + return new MessageReference(messageId, channelId, guildId, kind); } } diff --git a/DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs b/DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs new file mode 100644 index 00000000..6576a283 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs @@ -0,0 +1,8 @@ +namespace DiscordChatExporter.Core.Discord.Data; + +// https://discord.com/developers/docs/resources/channel#message-reference-types +public enum MessageReferenceKind +{ + Default = 0, + Forward = 1, +} diff --git a/DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs b/DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs new file mode 100644 index 00000000..bc51515b --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data.Embeds; +using DiscordChatExporter.Core.Utils.Extensions; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data; + +// https://discord.com/developers/docs/resources/channel#message-snapshot-object +// Message snapshots contain a subset of message fields for forwarded messages +public record MessageSnapshot( + string Content, + IReadOnlyList Attachments, + IReadOnlyList Embeds, + IReadOnlyList Stickers, + DateTimeOffset Timestamp, + DateTimeOffset? EditedTimestamp +) +{ + public static MessageSnapshot Parse(JsonElement json) + { + // The message snapshot has a "message" property containing the actual message data + var messageJson = json.GetPropertyOrNull("message") ?? json; + + var content = messageJson.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""; + + var attachments = + messageJson + .GetPropertyOrNull("attachments") + ?.EnumerateArrayOrNull() + ?.Select(Attachment.Parse) + .ToArray() ?? []; + + var embeds = + messageJson + .GetPropertyOrNull("embeds") + ?.EnumerateArrayOrNull() + ?.Select(Embed.Parse) + .ToArray() ?? []; + + var stickers = + messageJson + .GetPropertyOrNull("sticker_items") + ?.EnumerateArrayOrNull() + ?.Select(Sticker.Parse) + .ToArray() ?? []; + + var timestamp = + messageJson.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull() + ?? DateTimeOffset.MinValue; + + var editedTimestamp = messageJson + .GetPropertyOrNull("edited_timestamp") + ?.GetDateTimeOffsetOrNull(); + + return new MessageSnapshot( + content, + attachments, + embeds, + stickers, + timestamp, + editedTimestamp + ); + } +} diff --git a/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs b/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs index f6911ce3..ab1ec752 100644 --- a/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -92,6 +93,14 @@ internal partial class CsvMessageWriter(Stream stream, ExportContext context) { await _writer.WriteAsync(CsvEncode(message.GetFallbackContent())); } + else if (message.ForwardedMessage is not null) + { + // For forwarded messages, include the forwarded content + var forwardedContent = $"[Forwarded] {message.ForwardedMessage.Content}"; + await _writer.WriteAsync( + CsvEncode(await FormatMarkdownAsync(forwardedContent, cancellationToken)) + ); + } else { await _writer.WriteAsync( @@ -101,8 +110,11 @@ internal partial class CsvMessageWriter(Stream stream, ExportContext context) await _writer.WriteAsync(','); - // Attachments - await WriteAttachmentsAsync(message.Attachments, cancellationToken); + // Attachments (include forwarded attachments if present) + var allAttachments = message.ForwardedMessage is not null + ? message.Attachments.Concat(message.ForwardedMessage.Attachments).ToList() + : message.Attachments; + await WriteAttachmentsAsync(allAttachments, cancellationToken); await _writer.WriteAsync(','); // Reactions diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index 5b1dfe48..28f58b49 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -522,6 +522,70 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteString("messageId", message.Reference.MessageId?.ToString()); _writer.WriteString("channelId", message.Reference.ChannelId?.ToString()); _writer.WriteString("guildId", message.Reference.GuildId?.ToString()); + _writer.WriteString("type", message.Reference.Kind.ToString()); + _writer.WriteEndObject(); + } + + // Forwarded message + if (message.ForwardedMessage is not null) + { + _writer.WriteStartObject("forwardedMessage"); + + _writer.WriteString( + "content", + await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken) + ); + + _writer.WriteString( + "timestamp", + message.ForwardedMessage.Timestamp != DateTimeOffset.MinValue + ? Context.NormalizeDate(message.ForwardedMessage.Timestamp) + : null + ); + + _writer.WriteString( + "timestampEdited", + message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate) + ); + + // Forwarded attachments + _writer.WriteStartArray("attachments"); + foreach (var attachment in message.ForwardedMessage.Attachments) + { + _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(); + + // Forwarded embeds + _writer.WriteStartArray("embeds"); + foreach (var embed in message.ForwardedMessage.Embeds) + await WriteEmbedAsync(embed, cancellationToken); + _writer.WriteEndArray(); + + // Forwarded stickers + _writer.WriteStartArray("stickers"); + foreach (var sticker in message.ForwardedMessage.Stickers) + { + _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.WriteEndObject(); } diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index e0730553..8d56e251 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -262,6 +262,99 @@ } + @* Forwarded message content *@ + @if (message.IsForward && message.ForwardedMessage is not null) + { + var fwd = message.ForwardedMessage; +
+
+ + + + Forwarded +
+ + @* Forwarded text content *@ + @if (!string.IsNullOrWhiteSpace(fwd.Content)) + { +
+ @Html.Raw(await FormatMarkdownAsync(fwd.Content)) +
+ } + + @* Forwarded attachments *@ + @if (fwd.Attachments.Any()) + { +
+ @foreach (var attachment in fwd.Attachments) + { + @if (attachment.IsImage) + { + + @(attachment.Description ?? + + } + else if (attachment.IsVideo) + { + + } + else if (attachment.IsAudio) + { + + } + else + { +
+ + + + +
+ @attachment.FileSize +
+
+ } + } +
+ } + + @* Forwarded stickers *@ + @foreach (var sticker in fwd.Stickers) + { +
+ @if (sticker.IsImage) + { + Sticker + } + else if (sticker.Format == StickerFormat.Lottie) + { +
+ } +
+ } + + @* Forwarded timestamp *@ + @if (fwd.Timestamp != DateTimeOffset.MinValue) + { +
+ Originally sent: @FormatDate(fwd.Timestamp) + @if (fwd.EditedTimestamp is not null) + { + (edited) + } +
+ } +
+ } + @* Attachments *@ @foreach (var attachment in message.Attachments) { diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs index 85b46a64..ca5b6fb7 100644 --- a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -224,6 +225,34 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context) await _writer.WriteLineAsync(); } + private async ValueTask WriteForwardedMessageAsync( + MessageSnapshot forwardedMessage, + CancellationToken cancellationToken = default + ) + { + await _writer.WriteLineAsync("{Forwarded Message}"); + + if (!string.IsNullOrWhiteSpace(forwardedMessage.Content)) + { + await _writer.WriteLineAsync( + await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken) + ); + } + + if (forwardedMessage.Timestamp != DateTimeOffset.MinValue) + { + await _writer.WriteLineAsync( + $"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}" + ); + } + + await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken); + await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken); + await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken); + + await _writer.WriteLineAsync(); + } + public override async ValueTask WriteMessageAsync( Message message, CancellationToken cancellationToken = default @@ -248,6 +277,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context) await _writer.WriteLineAsync(); + // Forwarded message content + if (message.ForwardedMessage is not null) + { + await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken); + } + // Attachments, embeds, reactions, etc. await WriteAttachmentsAsync(message.Attachments, cancellationToken); await WriteEmbedsAsync(message.Embeds, cancellationToken); diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index 9e746e16..ab8608b8 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -304,6 +304,60 @@ unicode-bidi: bidi-override; } + .chatlog__forward-symbol { + height: 10px; + margin: 6px 4px 4px 36px; + border-left: 2px solid @Themed("#4f545c", "#c7ccd1"); + border-top: 2px solid @Themed("#4f545c", "#c7ccd1"); + border-radius: 8px 0 0 0; + } + + .chatlog__forwarded { + display: flex; + flex-direction: column; + margin-bottom: 0.15rem; + padding: 0.5rem; + border-left: 4px solid @Themed("#4f545c", "#c7ccd1"); + border-radius: 4px; + background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)"); + } + + .chatlog__forwarded-header { + display: flex; + align-items: center; + margin-bottom: 0.25rem; + color: @Themed("#b5b6b8", "#5f5f60"); + font-size: 0.75rem; + font-weight: 600; + } + + .chatlog__forwarded-icon { + width: 16px; + height: 16px; + margin-right: 0.25rem; + } + + .chatlog__forwarded-content { + color: @Themed("#dcddde", "#2e3338"); + font-size: 0.95rem; + } + + .chatlog__forwarded-attachments { + margin-top: 0.3rem; + } + + .chatlog__forwarded-attachment { + max-width: 300px; + max-height: 200px; + border-radius: 3px; + } + + .chatlog__forwarded-timestamp { + margin-top: 0.25rem; + color: @Themed("#a3a6aa", "#5e6772"); + font-size: 0.75rem; + } + .chatlog__system-notification-icon { width: 18px; height: 18px; @@ -997,6 +1051,9 @@ + + +