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/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..a57bcc9f 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,63 @@ 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 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)) { try {