From 857da7857b6020f64ba4ff9e8fe473ab0e978445 Mon Sep 17 00:00:00 2001 From: "Jacob P." Date: Mon, 22 Jun 2026 18:43:27 +0200 Subject: [PATCH] Move thread starter extraction logic into DiscordClient --- .../Discord/DiscordClient.cs | 46 +++++++++++++- .../Exporting/ChannelExporter.cs | 60 +------------------ 2 files changed, 45 insertions(+), 61 deletions(-) diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index 2fa51df4..8cb70df7 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -656,6 +656,33 @@ public class DiscordClient( .FirstOrDefault(m => m.Id == messageId); } + private async ValueTask ResolveThreadStarterMessageAsync( + Message message, + CancellationToken cancellationToken = default + ) + { + // Threads created from a message contain an empty THREAD_STARTER_MESSAGE placeholder at + // the top of their history (in place of the actual starter message) that merely points + // back to the originating message in the parent channel. Resolve the placeholder to that + // actual message so the thread's starter message appears in the output, in its correct + // chronological position, with its real content. + // This doesn't apply to forum/media posts, whose starter message is already a regular + // message in the thread's own history (i.e. not a placeholder). + // https://github.com/Tyrrrz/DiscordChatExporter/issues/1265 + if (message.Kind != MessageKind.ThreadStarterMessage) + return message; + + // The placeholder references the parent channel and the original message it points to. + if (message.Reference?.ChannelId is not { } channelId) + return null; + if (message.Reference?.MessageId is not { } messageId) + return null; + + // The original message may no longer be accessible (e.g. deleted), in which case the + // empty placeholder is dropped as well. + return await TryGetMessageAsync(channelId, messageId, cancellationToken); + } + public async IAsyncEnumerable GetMessagesAsync( Snowflake channelId, Snowflake? after = null, @@ -728,7 +755,15 @@ public class DiscordClient( ); } - yield return message; + // Thread starter messages are returned as empty placeholders; resolve them to + // the actual message they reference before yielding (or skip if unavailable). + var resolvedMessage = await ResolveThreadStarterMessageAsync( + message, + cancellationToken + ); + if (resolvedMessage is not null) + yield return resolvedMessage; + currentAfter = message.Id; } } @@ -796,7 +831,14 @@ public class DiscordClient( ); } - yield return message; + // Thread starter messages are returned as empty placeholders; resolve them to + // the actual message they reference before yielding (or skip if unavailable). + var resolvedMessage = await ResolveThreadStarterMessageAsync( + message, + cancellationToken + ); + if (resolvedMessage is not null) + yield return resolvedMessage; } currentBefore = messages.Last().Id; diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index a57bcc9f..83fc08eb 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord; @@ -82,63 +80,7 @@ public class ChannelExporter(DiscordClient discord) cancellationToken ); - // Threads created from a message don't include that message in their own history, even - // though it logically belongs to the thread as its very first message. Because the thread - // shares its ID with that starter message, we can fetch it from the parent channel and - // export it together with the rest of the thread's messages. - // This doesn't apply to forum/media posts, whose starter message is already part of the - // thread's own history. - // https://github.com/Tyrrrz/DiscordChatExporter/issues/1265 - var starterMessage = - request.Channel.IsThread - && request.Channel.Parent is { Kind: not ChannelKind.GuildForum } parent - ? await discord.TryGetMessageAsync(parent.Id, request.Channel.Id, cancellationToken) - : null; - - // Only include the starter message if it falls within the requested range - if ( - starterMessage is not null - && ( - (request.After is not null && starterMessage.Id < request.After.Value) - || (request.Before is not null && starterMessage.Id > request.Before.Value) - ) - ) - { - starterMessage = null; - } - - // Prepend the starter message to the thread's history, or append it when exporting in - // reverse order, so that it remains the oldest message in the output either way. - async IAsyncEnumerable GetMessagesWithStarter( - [EnumeratorCancellation] CancellationToken innerCancellationToken - ) - { - // If the message order is not reversed, the starter message should be the first one in the output, so we yield it before fetching the rest of the thread's history. - if (starterMessage is not null && !request.IsReverseMessageOrder) - yield return starterMessage; - - // Fetch the rest of the thread's history and yield messages one by one - await foreach (var message in messages.WithCancellation(innerCancellationToken)) - { - // A thread created from a message has an empty THREAD_STARTER_MESSAGE placeholder - // at the top of its history that merely points back to the originating message. - // It never carries any renderable content of its own and is superseded by the - // actual starter message fetched above, so skip it. - if (message.Kind == MessageKind.ThreadStarterMessage) - continue; - - // Avoid exporting the starter message twice in case it's also returned as part - // of the thread's own history. - if (message.Id != starterMessage?.Id) - yield return message; - } - - // If the message order is reversed, the starter message should be the last one in the output, so we yield it after fetching the rest of the thread's history. - if (starterMessage is not null && request.IsReverseMessageOrder) - yield return starterMessage; - } - - await foreach (var message in GetMessagesWithStarter(cancellationToken)) + await foreach (var message in messages) { try {