Compare commits

..

10 Commits

Author SHA1 Message Date
Tyrrrz cc92b2cbe6 More cleanup 2025-06-08 22:53:10 +03:00
Tyrrrz 08718425f1 Clean up 2025-06-08 22:37:24 +03:00
Tyrrrz 540ba34fb4 Rename solution items 2025-06-08 21:25:01 +03:00
Tyrrrz 22134108a4 Apply new formatting 2025-06-08 21:17:10 +03:00
dependabot[bot] 82499af25a Bump CliFx and 9 others (#1389)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-08 21:13:22 +03:00
Rimi Kanokawa 4a74ff8a02 Make the names assigned to unnamed group DMs predictable (#1386) 2025-06-02 02:01:15 +03:00
Mark Cilia Vincenti 7dfcf5ac19 Change InvalidFileNameChars to use FrozenSet instead of HashSet (#1384) 2025-06-02 01:48:25 +03:00
dependabot[bot] 19c5fb40b2 Bump codecov/codecov-action from 5.4.2 to 5.4.3 in the actions group (#1388)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 01:40:30 +03:00
audinowho d4fa8f0954 Support --include-threads in the export command (#1343) 2025-05-27 23:37:54 +03:00
Oleksii Holub 0d9168e0d2 Include notes about running the app on macOS in readme 2025-05-22 21:16:13 +03:00
15 changed files with 309 additions and 334 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -1,32 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UserSecretsId>d1fe5ae2-2a19-404d-a36e-81ba9eada1c1</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="8.3.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.5" />
<PackageReference
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
Version="9.0.5"
/>
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" PrivateAssets="all" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
</ItemGroup>
</Project>
</Project>
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
@@ -34,9 +35,9 @@ public abstract class DiscordCommandBase : ICommand
)]
public bool ShouldRespectRateLimits { get; init; } = true;
private DiscordClient? _discordClient;
[field: AllowNull, MaybeNull]
protected DiscordClient Discord =>
_discordClient ??= new DiscordClient(
field ??= new DiscordClient(
Token,
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
);
@@ -1,6 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@@ -8,6 +9,7 @@ using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
@@ -15,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress;
using Spectre.Console;
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class ExportCommandBase : DiscordCommandBase
{
private readonly string _outputPath = Directory.GetCurrentDirectory();
[CommandOption(
"output",
'o',
@@ -35,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
)]
public string OutputPath
{
get => _outputPath;
get;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _outputPath = Path.GetFullPath(value);
}
init => field = Path.GetFullPath(value);
} = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', Description = "Export format.")]
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
@@ -64,6 +63,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
)]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
[CommandOption(
"filter",
Description = "Only include messages that satisfy this filter. "
@@ -95,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
)]
public bool ShouldReuseAssets { get; init; } = false;
private readonly string? _assetsDirPath;
[CommandOption(
"media-dir",
Description = "Download assets to this directory. "
@@ -104,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
)]
public string? AssetsDirPath
{
get => _assetsDirPath;
get;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
init => field = value is not null ? Path.GetFullPath(value) : null;
}
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
@@ -136,11 +140,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
)]
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
private ChannelExporter? _channelExporter;
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
[field: AllowNull, MaybeNull]
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
{
var cancellationToken = console.RegisterCancellationHandler();
// Asset reuse can only be enabled if the download assets option is set
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseAssets && !ShouldDownloadAssets)
@@ -154,13 +160,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
throw new CommandException("Option --media-dir cannot be used without --media.");
}
var unwrappedChannels = new List<Channel>(channels);
// Unwrap threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync("Fetching threads...");
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetChannelThreadsAsync(
channels,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
unwrappedChannels.Add(thread);
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
fetchedThreadsCount++;
}
}
);
// Remove forums, as they cannot be exported directly and their constituent threads
// have already been fetched.
unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
}
// Make sure the user does not try to export multiple channels into one file.
// Output path must either be a directory or contain template tokens for this to work.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath =
// Anything is valid when exporting a single channel
channels.Count <= 1
unwrappedChannels.Count <= 1
// When using template tokens, assume the user knows what they're doing
|| OutputPath.Contains('%')
// Otherwise, require an existing directory or an unambiguous directory path
@@ -177,11 +223,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
}
// Export
var cancellationToken = console.RegisterCancellationHandler();
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)...");
await console
.CreateProgressTicker()
.HideCompleted(
@@ -193,7 +238,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
.StartAsync(async ctx =>
{
await Parallel.ForEachAsync(
channels,
unwrappedChannels,
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
@@ -253,7 +298,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.White))
{
await console.Output.WriteLineAsync(
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
$"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)."
);
}
@@ -301,48 +346,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Fail the command only if ALL channels failed to export.
// If only some channels failed to export, it's okay.
if (errorsByChannel.Count >= channels.Count)
if (errorsByChannel.Count >= unwrappedChannels.Count)
throw new CommandException("Export failed.");
}
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
{
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in channelIds)
{
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
// Unwrap categories
if (channel.IsCategory)
{
var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId)
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel);
}
// Cache the guild channels to avoid redundant work
channelsByGuild[channel.GuildId] = guildChannels;
}
else
{
channels.Add(channel);
}
}
await ExportAsync(console, channels);
}
public override async ValueTask ExecuteAsync(IConsole console)
{
// Support Ukraine callout
@@ -5,8 +5,6 @@ using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Dump;
@@ -27,13 +25,6 @@ public class ExportAllCommand : ExportCommandBase
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
[CommandOption(
"data-package",
Description = "Path to the personal data package (ZIP file) requested from Discord. "
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
);
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
// Threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync(
$"Fetching threads for server '{guild.Name}'..."
);
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetGuildThreadsAsync(
guild.Id,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
channels.Add(thread);
ctx.Status(
Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'.")
);
fetchedThreadsCount++;
}
}
);
await console.Output.WriteLineAsync(
$"Fetched {fetchedThreadsCount} thread(s)."
);
}
}
}
// Pull from the data package
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
channels.RemoveAll(c => c.IsGuild);
if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.IsVoice);
if (ThreadInclusionMode == ThreadInclusionMode.None)
channels.RemoveAll(c => c.IsThread);
if (ThreadInclusionMode != ThreadInclusionMode.All)
channels.RemoveAll(c => c is { IsThread: true, IsArchived: true });
await ExportAsync(console, channels);
}
@@ -4,6 +4,8 @@ using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
@@ -22,6 +24,40 @@ public class ExportChannelsCommand : ExportCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
await ExportAsync(console, ChannelIds);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in ChannelIds)
{
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
// Unwrap categories
if (channel.IsCategory)
{
var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId)
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel);
}
// Cache the guild channels to avoid redundant work
channelsByGuild[channel.GuildId] = guildChannels;
}
else
{
channels.Add(channel);
}
}
await ExportAsync(console, channels);
}
}
@@ -3,8 +3,6 @@ using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
@@ -21,13 +19,6 @@ public class ExportGuildCommand : ExportCommandBase
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
// Regular channels
await console.Output.WriteLineAsync("Fetching channels...");
var fetchedChannelsCount = 0;
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
// Threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync("Fetching threads...");
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetGuildThreadsAsync(
GuildId,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
channels.Add(thread);
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
fetchedThreadsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
}
await ExportAsync(console, channels);
}
}
@@ -1,22 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed>
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="CliFx" Version="2.3.6" />
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.50.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
</Project>
</Project>
@@ -78,6 +78,7 @@ public partial record Channel
?? json.GetPropertyOrNull("recipients")
?.EnumerateArrayOrNull()
?.Select(User.Parse)
.OrderBy(u => u.Id)
.Select(u => u.DisplayName)
.Pipe(s => string.Join(", ", s))
// Fallback
+168 -138
View File
@@ -305,146 +305,19 @@ public class DiscordClient(
if (guildId == Guild.DirectMessages.Id)
yield break;
var channels = (await GetGuildChannelsAsync(guildId, cancellationToken))
// Categories cannot have threads
.Where(c => !c.IsCategory)
// Voice channels cannot have threads
.Where(c => !c.IsVoice)
// Empty channels cannot have threads
.Where(c => !c.IsEmpty)
// If the 'before' boundary is specified, skip channels that don't have messages
// for that range, because thread-start event should always be accompanied by a message.
// Note that we don't perform a similar check for the 'after' boundary, because
// threads may have messages in range, even if the parent channel doesn't.
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
.ToArray();
var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
// User accounts can only fetch threads using the search endpoint
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
foreach (
var channel in await GetChannelThreadsAsync(
channels,
includeArchived,
before,
after,
cancellationToken
)
)
{
foreach (var channel in channels)
{
// Either include both active and archived threads, or only active threads
foreach (
var isArchived in includeArchived ? new[] { false, true } : new[] { false }
)
{
// Offset is just the index of the last thread in the previous batch
var currentOffset = 0;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/search")
.SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access or channels without threads
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
var breakOuter = false;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
// If the 'after' boundary is specified, we can break early,
// because threads are sorted by last message timestamp.
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
{
breakOuter = true;
break;
}
yield return thread;
currentOffset++;
}
if (breakOuter)
break;
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
}
// Bot accounts can only fetch threads using the threads endpoint
else
{
// Active threads
{
var parentsById = channels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync(
$"guilds/{guildId}/threads/active",
cancellationToken
);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
{
var parent = threadJson
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(threadJson, parent);
}
}
// Archived threads
if (includeArchived)
{
foreach (var channel in channels)
{
foreach (var archiveType in new[] { "public", "private" })
{
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
var currentBefore = before
?.ToDate()
.ToString("O", CultureInfo.InvariantCulture);
while (true)
{
// Threads are sorted by archive timestamp, not by last message timestamp
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/archived/{archiveType}")
.SetQueryParameter("before", currentBefore)
.Build();
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
foreach (
var threadJson in response
.Value.GetProperty("threads")
.EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
yield return thread;
currentBefore = threadJson
.GetProperty("thread_metadata")
.GetProperty("archive_timestamp")
.GetString();
}
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
}
yield return channel;
}
}
@@ -515,6 +388,163 @@ public class DiscordClient(
}
}
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
IReadOnlyList<Channel> channels,
bool includeArchived = false,
Snowflake? before = null,
Snowflake? after = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
var filteredChannels = channels
// Categories cannot have threads
.Where(c => !c.IsCategory)
// Voice channels cannot have threads
.Where(c => !c.IsVoice)
// Empty channels cannot have threads
.Where(c => !c.IsEmpty)
// If the 'before' boundary is specified, skip channels that don't have messages
// for that range, because thread-start event should always be accompanied by a message.
// Note that we don't perform a similar check for the 'after' boundary, because
// threads may have messages in range, even if the parent channel doesn't.
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
.ToArray();
// User accounts can only fetch threads using the search endpoint
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
{
foreach (var channel in filteredChannels)
{
// Either include both active and archived threads, or only active threads
foreach (
var isArchived in includeArchived ? new[] { false, true } : new[] { false }
)
{
// Offset is just the index of the last thread in the previous batch
var currentOffset = 0;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/search")
.SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access or channels without threads
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
var breakOuter = false;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
// If the 'after' boundary is specified, we can break early,
// because threads are sorted by last message timestamp.
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
{
breakOuter = true;
break;
}
yield return thread;
currentOffset++;
}
if (breakOuter)
break;
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
}
// Bot accounts can only fetch threads using the threads endpoint
else
{
var guilds = new HashSet<Snowflake>();
foreach (var channel in filteredChannels)
guilds.Add(channel.GuildId);
// Active threads
foreach (var guildId in guilds)
{
var parentsById = filteredChannels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync(
$"guilds/{guildId}/threads/active",
cancellationToken
);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
{
var parent = threadJson
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
if (filteredChannels.Contains(parent))
yield return Channel.Parse(threadJson, parent);
}
}
// Archived threads
if (includeArchived)
{
foreach (var channel in filteredChannels)
{
foreach (var archiveType in new[] { "public", "private" })
{
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
var currentBefore = before
?.ToDate()
.ToString("O", CultureInfo.InvariantCulture);
while (true)
{
// Threads are sorted by archive timestamp, not by last message timestamp
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/archived/{archiveType}")
.SetQueryParameter("before", currentBefore)
.Build();
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
foreach (
var threadJson in response
.Value.GetProperty("threads")
.EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
yield return thread;
currentBefore = threadJson
.GetProperty("thread_metadata")
.GetProperty("archive_timestamp")
.GetString();
}
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
}
}
}
private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId,
Snowflake? before = null,
@@ -1,15 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="7.1.6" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.5.2" />
<PackageReference Include="RazorBlade" Version="0.9.0" />
<PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
<PackageReference Include="YoutubeExplode" Version="6.5.4" />
</ItemGroup>
</Project>
+2 -2
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.Frozen;
using System.IO;
using System.Text;
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Utils;
public static class PathEx
{
private static readonly HashSet<char> InvalidFileNameChars =
private static readonly FrozenSet<char> InvalidFileNameChars =
[
.. Path.GetInvalidFileNameChars(),
];
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<AssemblyName>DiscordChatExporter</AssemblyName>
@@ -8,34 +7,36 @@
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference
Include="Avalonia.Diagnostics"
Version="11.3.0"
Condition="'$(Configuration)' == 'Debug'"
/>
<PackageReference Include="Cogwheel" Version="2.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
<PackageReference Include="DialogHost.Avalonia" Version="0.9.2" />
<PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" />
<PackageReference Include="Onova" Version="2.6.13" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)" LogStandardErrorAsError="true" />
<Exec
Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)"
LogStandardErrorAsError="true"
/>
</Target>
</Project>
</Project>
+1 -1
View File
@@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.28729.10
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
ProjectSection(SolutionItems) = preProject
License.txt = License.txt
Readme.md = Readme.md
+5 -1
View File
@@ -50,6 +50,10 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
> **Important**:
> To launch the GUI version of the app on MacOS, you need to first remove the downloaded file from quarantine.
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
> **Note**:
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
@@ -74,4 +78,4 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
## See also
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.