Rework CLI commands to be composable: pipe channels to export, remove exportguild/exportall/exportdm

Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/5f305835-64af-456e-b0b4-6163ece1e8cf

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-03 14:42:04 +00:00 committed by GitHub
parent 716ea79f60
commit efb371f093
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 126 additions and 322 deletions

View file

@ -34,11 +34,8 @@ Type the following command in your terminal of choice, then press ENTER to run i
## CLI commands ## CLI commands
| Command | Description | | Command | Description |
| ----------- | ---------------------------------------------------- | | -------- | ---------------------------------------------------- |
| export | Exports a channel | | export | Exports one or more channels |
| exportdm | Exports all direct message channels |
| exportguild | Exports all channels within the specified server |
| exportall | Exports all accessible channels |
| channels | Outputs the list of channels in the given server | | channels | Outputs the list of channels in the given server |
| dm | Outputs the list of direct message channels | | dm | Outputs the list of direct message channels |
| guilds | Outputs the list of accessible servers | | guilds | Outputs the list of accessible servers |
@ -225,46 +222,38 @@ Documentation on message filter syntax can be found [here](https://github.com/Ty
### Export channels from a specific server ### Export channels from a specific server
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option: To export all channels in a specific server, use the `channels` command to list channels and pipe the result to `export`:
```console ```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814 | ./DiscordChatExporter.Cli export -t "mfa.Ifrn"
``` ```
> **Tip**: To avoid repeating `--token` (or `-t`) twice, set the `DISCORD_TOKEN` environment variable.
#### Including threads #### Including threads
By default, threads are not included in the export. You can change this behavior by using `--include-threads` and By default, threads are not included. You can change this behavior by passing `--include-threads` to the `channels` command. It has possible values of `none`, `active`, or `all`, indicating which threads should be included. To include both active and archived threads, use `--include-threads all`.
specifying which threads should be included. It has possible values of `none`, `active`, or `all`, indicating which
threads should be included. To include both active and archived threads, use `--include-threads all`.
```console ```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814 --include-threads all | ./DiscordChatExporter.Cli export -t "mfa.Ifrn"
``` ```
#### Including voice channels #### Including voice channels
By default, voice channels are included in the export. You can change this behavior by using `--include-vc` and By default, voice channels are included. You can change this behavior by passing `--include-vc false` to the `channels` command.
specifying whether to include voice channels in the export. It has possible values of `true` or `false`, to exclude
voice channels, use `--include-vc false`.
```console ```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814 --include-vc false | ./DiscordChatExporter.Cli export -t "mfa.Ifrn"
``` ```
### Export all channels ### Export all channels
To export all accessible channels, use the `exportall` command: To export all accessible channels, first list all guilds and then pipe each guild's channels to `export`. You can also use `dm` to include direct message channels.
To export all DMs:
```console ```console
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" ./DiscordChatExporter.Cli dm -t "mfa.Ifrn" | ./DiscordChatExporter.Cli export -t "mfa.Ifrn"
```
#### Excluding DMs
To exclude DMs, add the `--include-dm false` option.
```console
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
``` ```
### List channels in a server ### List channels in a server
@ -275,6 +264,12 @@ To list the channels available in a specific server, use the `channels` command
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814 ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
``` ```
When the output is redirected or piped, the `channels` command prints only channel IDs (one per line). This allows you to pipe the output directly to the `export` command:
```console
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814 | ./DiscordChatExporter.Cli export -t "mfa.Ifrn"
```
### List direct message channels ### List direct message channels
To list all DM channels accessible to the current account, use the `dm` command: To list all DM channels accessible to the current account, use the `dm` command:
@ -283,6 +278,12 @@ To list all DM channels accessible to the current account, use the `dm` command:
./DiscordChatExporter.Cli dm -t "mfa.Ifrn" ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
``` ```
When the output is redirected or piped, the `dm` command prints only channel IDs (one per line). This allows you to pipe the output directly to the `export` command:
```console
./DiscordChatExporter.Cli dm -t "mfa.Ifrn" | ./DiscordChatExporter.Cli export -t "mfa.Ifrn"
```
### List servers ### List servers
To list all servers accessible by the current account, use the `guilds` command: To list all servers accessible by the current account, use the `guilds` command:

View file

@ -1,156 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Dump;
using DiscordChatExporter.Core.Exceptions;
using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportall", Description = "Exports all accessible channels.")]
public partial class ExportAllCommand : ExportCommandBase
{
[CommandOption("include-dm", Description = "Include direct message channels.")]
public bool IncludeDirectChannels { get; set; } = true;
[CommandOption("include-guilds", Description = "Include server channels.")]
public bool IncludeGuildChannels { get; set; } = true;
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; set; } = true;
[CommandOption(
"data-package",
Description = "Path to the personal data package (ZIP file) requested from Discord. "
+ "If provided, only channels referenced in the dump will be exported."
)]
public string? DataPackageFilePath { get; set; }
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
// Pull from the API
if (string.IsNullOrWhiteSpace(DataPackageFilePath))
{
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
{
// Regular channels
await console.Output.WriteLineAsync(
$"Fetching channels for server '{guild.Name}'..."
);
var fetchedChannelsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var channel in Discord.GetGuildChannelsAsync(
guild.Id,
cancellationToken
)
)
{
if (channel.IsCategory)
continue;
if (!IncludeVoiceChannels && channel.IsVoice)
continue;
channels.Add(channel);
ctx.Status(
Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'.")
);
fetchedChannelsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
}
}
// Pull from the data package
else
{
await console.Output.WriteLineAsync("Extracting channels...");
var dump = await DataDump.LoadAsync(DataPackageFilePath, cancellationToken);
var inaccessibleChannels = new List<DataDumpChannel>();
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
foreach (var dumpChannel in dump.Channels)
{
ctx.Status(
Markup.Escape(
$"Fetching '{dumpChannel.Name}' ({dumpChannel.Id})..."
)
);
try
{
var channel = await Discord.GetChannelAsync(
dumpChannel.Id,
cancellationToken
);
channels.Add(channel);
}
catch (DiscordChatExporterException)
{
inaccessibleChannels.Add(dumpChannel);
}
}
}
);
await console.Output.WriteLineAsync($"Fetched {channels} channel(s).");
// Print inaccessible channels
if (inaccessibleChannels.Any())
{
await console.Output.WriteLineAsync();
using (console.WithForegroundColor(ConsoleColor.Red))
{
await console.Error.WriteLineAsync(
"Failed to access the following channel(s):"
);
}
foreach (var dumpChannel in inaccessibleChannels)
await console.Error.WriteLineAsync($"{dumpChannel.Name} ({dumpChannel.Id})");
await console.Error.WriteLineAsync();
}
}
// Filter out unwanted channels
if (!IncludeDirectChannels)
channels.RemoveAll(c => c.IsDirect);
if (!IncludeGuildChannels)
channels.RemoveAll(c => c.IsGuild);
if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.IsVoice);
await ExportAsync(console, channels);
}
}

View file

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Binding; using CliFx.Binding;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
@ -18,8 +19,10 @@ public partial class ExportChannelsCommand : ExportCommandBase
'c', 'c',
Description = "Channel ID(s). " Description = "Channel ID(s). "
+ "If provided with category ID(s), all channels inside those categories will be exported. " + "If provided with category ID(s), all channels inside those categories will be exported. "
+ "If not provided, channel IDs are read from standard input (one per line), "
+ "enabling piping from the 'channels' or 'dm' commands."
)] )]
public required IReadOnlyList<Snowflake> ChannelIds { get; set; } public IReadOnlyList<Snowflake> ChannelIds { get; set; } = [];
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@ -27,12 +30,33 @@ public partial class ExportChannelsCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
// If no channel IDs were specified, read them from stdin
var channelIds = new List<Snowflake>(ChannelIds);
if (channelIds.Count == 0 && console.IsInputRedirected)
{
string? line;
while ((line = await console.Input.ReadLineAsync()) is not null)
{
line = line.Trim();
if (!string.IsNullOrEmpty(line))
channelIds.Add(Snowflake.Parse(line));
}
}
if (channelIds.Count == 0)
{
throw new CommandException(
"No channel IDs provided. "
+ "Specify channel IDs via the '--channel' option or pipe them from the 'channels' or 'dm' commands."
);
}
await console.Output.WriteLineAsync("Resolving channel(s)..."); await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>(); var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>(); var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in ChannelIds) foreach (var channelId in channelIds)
{ {
var channel = await Discord.GetChannelAsync(channelId, cancellationToken); var channel = await Discord.GetChannelAsync(channelId, cancellationToken);

View file

@ -1,27 +0,0 @@
using System.Threading.Tasks;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportdm", Description = "Exports all direct message channels.")]
public partial class ExportDirectMessagesCommand : ExportCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(
Guild.DirectMessages.Id,
cancellationToken
);
await ExportAsync(console, channels);
}
}

View file

@ -1,61 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Exports all channels within the specified server.")]
public partial class ExportGuildCommand : ExportCommandBase
{
[CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; set; }
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; set; } = true;
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
await console.Output.WriteLineAsync("Fetching channels...");
var fetchedChannelsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var channel in Discord.GetGuildChannelsAsync(GuildId, cancellationToken)
)
{
if (channel.IsCategory)
continue;
if (!IncludeVoiceChannels && channel.IsVoice)
continue;
channels.Add(channel);
ctx.Status(Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'."));
fetchedChannelsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
await ExportAsync(console, channels);
}
}

View file

@ -60,6 +60,19 @@ public partial class GetChannelsCommand : DiscordCommandBase
.ToArray() .ToArray()
: []; : [];
// If output is redirected, print only channel IDs (one per line) for easy piping
if (console.IsOutputRedirected)
{
foreach (var channel in channels)
{
await console.Output.WriteLineAsync(channel.Id.ToString());
foreach (var channelThread in threads.Where(t => t.Parent?.Id == channel.Id))
await console.Output.WriteLineAsync(channelThread.Id.ToString());
}
}
else
{
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Channel ID // Channel ID
@ -112,3 +125,4 @@ public partial class GetChannelsCommand : DiscordCommandBase
} }
} }
} }
}

View file

@ -30,6 +30,14 @@ public partial class GetDirectChannelsCommand : DiscordCommandBase
.OrderDescending() .OrderDescending()
.FirstOrDefault(); .FirstOrDefault();
// If output is redirected, print only channel IDs (one per line) for easy piping
if (console.IsOutputRedirected)
{
foreach (var channel in channels)
await console.Output.WriteLineAsync(channel.Id.ToString());
}
else
{
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Channel ID // Channel ID
@ -47,3 +55,4 @@ public partial class GetDirectChannelsCommand : DiscordCommandBase
} }
} }
} }
}