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 <noreply@anthropic.com>
This commit is contained in:
Jacob P. 2026-06-17 14:33:10 +02:00
parent 5a57c4202d
commit 6a8eeda9e5
No known key found for this signature in database
GPG key ID: CC10D6AE715DAEDF
2 changed files with 76 additions and 1 deletions

View file

@ -629,6 +629,33 @@ public class DiscordClient(
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
}
public async ValueTask<Message?> 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<Message> GetMessagesAsync(
Snowflake channelId,
Snowflake? after = null,

View file

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