Move thread starter extraction logic into DiscordClient

This commit is contained in:
Jacob P. 2026-06-22 18:43:27 +02:00
parent 38f9b9c676
commit 857da7857b
No known key found for this signature in database
GPG key ID: CC10D6AE715DAEDF
2 changed files with 45 additions and 61 deletions

View file

@ -656,6 +656,33 @@ public class DiscordClient(
.FirstOrDefault(m => m.Id == messageId);
}
private async ValueTask<Message?> 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<Message> 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;

View file

@ -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<Message> 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
{