diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlMentionSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlMentionSpecs.cs index afb21b8f..13374a74 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlMentionSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlMentionSpecs.cs @@ -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"); + } } diff --git a/DiscordChatExporter.Cli.Tests/Specs/JsonMentionSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/JsonMentionSpecs.cs index 171dbea6..bb27614e 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/JsonMentionSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/JsonMentionSpecs.cs @@ -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"); + } } diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index 5ac33d55..28d5e255 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -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 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 GetChannelThreadsAsync( diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs index 0ecc12b1..4b78e41a 100644 --- a/DiscordChatExporter.Core/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs @@ -16,7 +16,7 @@ namespace DiscordChatExporter.Core.Exporting; internal class ExportContext(DiscordClient discord, ExportRequest request) { private readonly Dictionary _membersById = new(); - private readonly Dictionary _channelsById = new(); + private readonly Dictionary _channelsById = new(); private readonly Dictionary _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, diff --git a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs index 8445d9b5..407913f6 100644 --- a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs @@ -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"; diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs index 5e7a0ca8..95513ba4 100644 --- a/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs @@ -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";