From 6a8eeda9e52ed6c5a4ba5b14c20b54c0723117f9 Mon Sep 17 00:00:00 2001 From: "Jacob P." Date: Wed, 17 Jun 2026 14:33:10 +0200 Subject: [PATCH 1/3] Include thread starter message when exporting threads (#1265) Threads created from a message in a regular text/announcement channel don't include that message in their own message history, even though it logically belongs to the thread as its first message. Because the thread shares its ID with the starter message, fetch it from the parent channel and export it ahead of (or after, in reverse mode) the thread's messages. Forum/media posts are unaffected, as their starter message is already part of the thread's own history. Co-Authored-By: Claude Opus 4.8 --- .../Discord/DiscordClient.cs | 27 ++++++++++ .../Exporting/ChannelExporter.cs | 50 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index f435e282..2fa51df4 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -629,6 +629,33 @@ public class DiscordClient( return response.EnumerateArray().Select(Message.Parse).LastOrDefault(); } + public async ValueTask TryGetMessageAsync( + Snowflake channelId, + Snowflake messageId, + CancellationToken cancellationToken = default + ) + { + // Use the regular message listing endpoint with the 'around' parameter instead of the + // dedicated single-message endpoint, because the latter is not accessible to user tokens. + var url = new UrlBuilder() + .SetPath($"channels/{channelId}/messages") + .SetQueryParameter("around", messageId.ToString()) + .SetQueryParameter("limit", "1") + .Build(); + + // Can be null on channels that the user cannot access + var response = await TryGetJsonResponseAsync(url, cancellationToken); + if (response is null) + return null; + + // The endpoint returns messages around the requested ID, so make sure to only return + // the message that exactly matches it (it may be absent if it has been deleted). + return response + .Value.EnumerateArray() + .Select(Message.Parse) + .FirstOrDefault(m => m.Id == messageId); + } + public async IAsyncEnumerable GetMessagesAsync( Snowflake channelId, Snowflake? after = null, diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 83fc08eb..5af52480 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord; @@ -80,7 +82,53 @@ public class ChannelExporter(DiscordClient discord) cancellationToken ); - await foreach (var message in messages) + // 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 (starterMessage is not null && !request.IsReverseMessageOrder) + yield return starterMessage; + + await foreach (var message in messages.WithCancellation(innerCancellationToken)) + { + // 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 (starterMessage is not null && request.IsReverseMessageOrder) + yield return starterMessage; + } + + await foreach (var message in GetMessagesWithStarter(cancellationToken)) { try { From f9a800e667687c3f8ffd9110dd21fe80adf2e921 Mon Sep 17 00:00:00 2001 From: "Jacob P." Date: Fri, 19 Jun 2026 01:37:00 +0200 Subject: [PATCH 2/3] Add comments --- DiscordChatExporter.Core/Exporting/ChannelExporter.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 5af52480..00ae2476 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -113,9 +113,11 @@ public class ChannelExporter(DiscordClient discord) [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)) { // Avoid exporting the starter message twice in case it's also returned as part @@ -124,6 +126,7 @@ public class ChannelExporter(DiscordClient discord) 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; } From 38f9b9c6764dfc01c325c193b8dd20700ce6adfd Mon Sep 17 00:00:00 2001 From: "Jacob P." Date: Fri, 19 Jun 2026 01:45:56 +0200 Subject: [PATCH 3/3] Skip empty THREAD_STARTER_MESSAGE placeholder in thread exports A thread created from a message has an empty THREAD_STARTER_MESSAGE (type 21) placeholder at the top of its history that just points back to the originating message. Now that the actual starter message is fetched and exported separately, this placeholder is redundant noise, so skip it. Co-Authored-By: Claude Opus 4.8 --- DiscordChatExporter.Core/Discord/Data/MessageKind.cs | 1 + DiscordChatExporter.Core/Exporting/ChannelExporter.cs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/DiscordChatExporter.Core/Discord/Data/MessageKind.cs b/DiscordChatExporter.Core/Discord/Data/MessageKind.cs index c2aba207..07eb431a 100644 --- a/DiscordChatExporter.Core/Discord/Data/MessageKind.cs +++ b/DiscordChatExporter.Core/Discord/Data/MessageKind.cs @@ -13,4 +13,5 @@ public enum MessageKind GuildMemberJoin = 7, ThreadCreated = 18, Reply = 19, + ThreadStarterMessage = 21, } diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 00ae2476..a57bcc9f 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -120,6 +120,13 @@ public class ChannelExporter(DiscordClient discord) // 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)