mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc92b2cbe6 | |||
| 08718425f1 | |||
| 540ba34fb4 | |||
| 22134108a4 | |||
| 82499af25a | |||
| 4a74ff8a02 | |||
| 7dfcf5ac19 | |||
| 19c5fb40b2 | |||
| d4fa8f0954 | |||
| 0d9168e0d2 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
Reference in New Issue
Block a user