This commit is contained in:
Tyrrrz 2025-06-08 22:37:24 +03:00
parent 540ba34fb4
commit 08718425f1
6 changed files with 79 additions and 130 deletions

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
@ -34,9 +35,9 @@ public abstract class DiscordCommandBase : ICommand
)] )]
public bool ShouldRespectRateLimits { get; init; } = true; public bool ShouldRespectRateLimits { get; init; } = true;
private DiscordClient? _discordClient; [field: AllowNull, MaybeNull]
protected DiscordClient Discord => protected DiscordClient Discord =>
_discordClient ??= new DiscordClient( field ??= new DiscordClient(
Token, Token,
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
); );

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -16,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress; using Gress;
using Spectre.Console; using Spectre.Console;
@ -24,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class ExportCommandBase : DiscordCommandBase public abstract class ExportCommandBase : DiscordCommandBase
{ {
private readonly string _outputPath = Directory.GetCurrentDirectory();
[CommandOption( [CommandOption(
"output", "output",
'o', 'o',
@ -36,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public string OutputPath public string OutputPath
{ {
get => _outputPath; get;
// Handle ~/ in paths on Unix systems // Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903 // 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.")] [CommandOption("format", 'f', Description = "Export format.")]
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark; public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
@ -103,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public bool ShouldReuseAssets { get; init; } = false; public bool ShouldReuseAssets { get; init; } = false;
private readonly string? _assetsDirPath;
[CommandOption( [CommandOption(
"media-dir", "media-dir",
Description = "Download assets to this directory. " Description = "Download assets to this directory. "
@ -112,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public string? AssetsDirPath public string? AssetsDirPath
{ {
get => _assetsDirPath; get;
// Handle ~/ in paths on Unix systems // Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903 // 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.")] [Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
@ -144,8 +140,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public bool IsUkraineSupportMessageDisabled { get; init; } = false; public bool IsUkraineSupportMessageDisabled { get; init; } = false;
private ChannelExporter? _channelExporter; [field: AllowNull, MaybeNull]
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord); protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
@ -353,44 +349,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
throw new CommandException("Export failed."); 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) public override async ValueTask ExecuteAsync(IConsole console)
{ {
// Support Ukraine callout // Support Ukraine callout

View File

@ -5,8 +5,6 @@ using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Dump; using DiscordChatExporter.Core.Discord.Dump;

View File

@ -1,16 +1,11 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; 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;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;

View File

@ -3,8 +3,6 @@ using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
@ -28,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>(); var channels = new List<Channel>();
// Regular channels
await console.Output.WriteLineAsync("Fetching channels..."); await console.Output.WriteLineAsync("Fetching channels...");
var fetchedChannelsCount = 0; var fetchedChannelsCount = 0;

View File

@ -321,6 +321,73 @@ public class DiscordClient(
} }
} }
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
if (guildId == Guild.DirectMessages.Id)
yield break;
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
foreach (var roleJson in response.EnumerateArray())
yield return Role.Parse(roleJson);
}
public async ValueTask<Member?> TryGetGuildMemberAsync(
Snowflake guildId,
Snowflake memberId,
CancellationToken cancellationToken = default
)
{
if (guildId == Guild.DirectMessages.Id)
return null;
var response = await TryGetJsonResponseAsync(
$"guilds/{guildId}/members/{memberId}",
cancellationToken
);
return response?.Pipe(j => Member.Parse(j, guildId));
}
public async ValueTask<Invite?> TryGetInviteAsync(
string code,
CancellationToken cancellationToken = default
)
{
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
return response?.Pipe(Invite.Parse);
}
public async ValueTask<Channel> GetChannelAsync(
Snowflake channelId,
CancellationToken cancellationToken = default
)
{
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
var parentId = response
.GetPropertyOrNull("parent_id")
?.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)
{
return Channel.Parse(response);
}
}
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync( public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
IEnumerable<Channel> channels, IEnumerable<Channel> channels,
bool includeArchived = false, bool includeArchived = false,
@ -329,7 +396,7 @@ public class DiscordClient(
[EnumeratorCancellation] CancellationToken cancellationToken = default [EnumeratorCancellation] CancellationToken cancellationToken = default
) )
{ {
Channel[] filteredChannels = channels var filteredChannels = channels
// Categories cannot have threads // Categories cannot have threads
.Where(c => !c.IsCategory) .Where(c => !c.IsCategory)
// Voice channels cannot have threads // Voice channels cannot have threads
@ -478,73 +545,6 @@ public class DiscordClient(
} }
} }
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
if (guildId == Guild.DirectMessages.Id)
yield break;
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
foreach (var roleJson in response.EnumerateArray())
yield return Role.Parse(roleJson);
}
public async ValueTask<Member?> TryGetGuildMemberAsync(
Snowflake guildId,
Snowflake memberId,
CancellationToken cancellationToken = default
)
{
if (guildId == Guild.DirectMessages.Id)
return null;
var response = await TryGetJsonResponseAsync(
$"guilds/{guildId}/members/{memberId}",
cancellationToken
);
return response?.Pipe(j => Member.Parse(j, guildId));
}
public async ValueTask<Invite?> TryGetInviteAsync(
string code,
CancellationToken cancellationToken = default
)
{
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
return response?.Pipe(Invite.Parse);
}
public async ValueTask<Channel> GetChannelAsync(
Snowflake channelId,
CancellationToken cancellationToken = default
)
{
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
var parentId = response
.GetPropertyOrNull("parent_id")
?.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)
{
return Channel.Parse(response);
}
}
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId, Snowflake channelId,
Snowflake? before = null, Snowflake? before = null,