Resolve thread mentions on demand (#1480)
Some checks failed
docker / pack (push) Has been cancelled
docker / deploy (push) Has been cancelled
main / format (push) Has been cancelled
main / test (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, linux-arm) (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, linux-arm64) (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, linux-musl-x64) (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, linux-x64) (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, osx-arm64) (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, osx-x64) (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, win-arm64) (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, win-x64) (push) Has been cancelled
main / pack (DiscordChatExporter.Cli, DiscordChatExporter.Cli, win-x86) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, linux-arm) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, linux-arm64) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, linux-musl-x64) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, linux-x64) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, osx-arm64) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, osx-x64) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, win-arm64) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, win-x64) (push) Has been cancelled
main / pack (DiscordChatExporter.Gui, DiscordChatExporter, win-x86) (push) Has been cancelled
main / release (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, linux-arm) (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, linux-arm64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, linux-musl-x64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, linux-x64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, osx-arm64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, osx-x64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, win-arm64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, win-x64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Cli, DiscordChatExporter.Cli, win-x86) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, linux-arm) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, linux-arm64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, linux-musl-x64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, linux-x64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, osx-arm64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, osx-x64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, win-arm64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, win-x64) (push) Has been cancelled
main / deploy (DiscordChatExporter.Gui, DiscordChatExporter, win-x86) (push) Has been cancelled
main / notify (push) Has been cancelled

* Initial plan

* Fix unresolved thread mentions in HTML export (#1261)

- Add TryGetChannelAsync to DiscordClient for on-demand channel/thread lookup
- Add PopulateChannelAsync to ExportContext with negative caching
- Update HtmlMarkdownVisitor to resolve thread mentions on demand

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Refactor GetChannelAsync to use TryGetChannelAsync for parent resolution

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Add test for thread mention resolution in HTML export

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Apply PopulateChannelAsync to PlainTextMarkdownVisitor; add JSON thread mention test

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
This commit is contained in:
Copilot 2026-02-21 23:21:47 +02:00 committed by GitHub
parent 72f9e981de
commit dd7196b6a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 87 additions and 11 deletions

View file

@ -61,4 +61,17 @@ public class HtmlMentionSpecs
// Assert
message.Text().Should().Contain("Role mention: @Role 1");
}
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MentionTestCases,
Snowflake.Parse("1474874276828938290")
);
// Assert
message.Text().Should().Contain("Thread mention: #Thread starting message");
}
}

View file

@ -75,4 +75,21 @@ public class JsonMentionSpecs
// Assert
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
}
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.MentionTestCases,
Snowflake.Parse("1474874276828938290")
);
// Assert
message
.GetProperty("content")
.GetString()
.Should()
.Be("Thread mention: #Thread starting message");
}
}

View file

@ -371,21 +371,40 @@ public class DiscordClient(
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
try
{
var parent = parentId is not null
? await GetChannelAsync(parentId.Value, cancellationToken)
: null;
return Channel.Parse(response, parent);
}
// It's possible for the parent channel to be inaccessible, despite the
// child channel being accessible.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
catch (DiscordChatExporterException)
var parent = parentId is not null
? await TryGetChannelAsync(parentId.Value, cancellationToken)
: null;
return Channel.Parse(response, parent);
}
public async ValueTask<Channel?> TryGetChannelAsync(
Snowflake channelId,
CancellationToken cancellationToken = default
)
{
var response = await TryGetJsonResponseAsync($"channels/{channelId}", cancellationToken);
if (response is null)
return null;
var parentId = response
.Value.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
Channel? parent = null;
if (parentId is not null)
{
return Channel.Parse(response);
// It's possible for the parent channel to be inaccessible, despite the
// child channel being accessible.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
parent = await TryGetChannelAsync(parentId.Value, cancellationToken);
}
return Channel.Parse(response.Value, parent);
}
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(

View file

@ -16,7 +16,7 @@ namespace DiscordChatExporter.Core.Exporting;
internal class ExportContext(DiscordClient discord, ExportRequest request)
{
private readonly Dictionary<Snowflake, Member?> _membersById = new();
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
private readonly Dictionary<Snowflake, Channel?> _channelsById = new();
private readonly Dictionary<Snowflake, Role> _rolesById = new();
private readonly ExportAssetDownloader _assetDownloader = new(
@ -51,6 +51,21 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
}
}
// Threads are not preloaded, so we resolve them on demand
public async ValueTask PopulateChannelAsync(
Snowflake id,
CancellationToken cancellationToken = default
)
{
if (_channelsById.ContainsKey(id))
return;
var channel = await Discord.TryGetChannelAsync(id, cancellationToken);
// Store the result even if it's null, to avoid re-fetching non-existing channels
_channelsById[id] = channel;
}
// Because members cannot be pulled in bulk, we need to populate them on demand
private async ValueTask PopulateMemberAsync(
Snowflake id,

View file

@ -270,6 +270,12 @@ internal partial class HtmlMarkdownVisitor(
}
else if (mention.Kind == MentionKind.Channel)
{
// Channel/thread mentions may reference threads that are not preloaded,
// so we resolve them on demand.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
if (mention.TargetId is not null)
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
var symbol = channel?.IsVoice == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel";

View file

@ -57,6 +57,12 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
}
else if (mention.Kind == MentionKind.Channel)
{
// Channel/thread mentions may reference threads that are not preloaded,
// so we resolve them on demand.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
if (mention.TargetId is not null)
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel";