Resolve thread mentions on demand (#1480)

* 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
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";