This commit is contained in:
Jacob Pfundstein
2026-06-20 07:23:03 -04:00
committed by GitHub
3 changed files with 87 additions and 1 deletions

View File

@@ -13,4 +13,5 @@ public enum MessageKind
GuildMemberJoin = 7,
ThreadCreated = 18,
Reply = 19,
ThreadStarterMessage = 21,
}

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,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<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))
{
try
{