From c38953d8681b7b7d422feb97fdeb2d7265ceae9a Mon Sep 17 00:00:00 2001 From: primetime43 <12754111+primetime43@users.noreply.github.com> Date: Fri, 15 May 2026 03:02:14 -0400 Subject: [PATCH 1/7] Add message deletion feature (CLI & GUI) Introduce a message-deletion feature across CLI and GUI. Adds a new CLI command (deletemessages) with channel/before/after options and console progress. Extends DiscordClient with GetCurrentUserAsync and DeleteMessageAsync (including rate-limit handling) to perform deletions and surface authorization outcomes. GUI additions include DeleteSetup dialog, its ViewModel, view, and wiring: App registration, View/ViewModel managers, DashboardViewModel command, and a Delete button in the dashboard; deletion runs per-channel (parallel, with progress) and reports success/failure summaries. --- .../Commands/DeleteMessagesCommand.cs | 175 +++++++++++++++++ .../Discord/DiscordClient.cs | 98 ++++++++++ DiscordChatExporter.Gui/App.axaml.cs | 1 + .../Framework/ViewManager.cs | 1 + .../Framework/ViewModelManager.cs | 13 ++ .../Components/DashboardViewModel.cs | 126 +++++++++++- .../Dialogs/DeleteSetupViewModel.cs | 57 ++++++ .../Views/Components/DashboardView.axaml | 55 ++++-- .../Views/Dialogs/DeleteSetupView.axaml | 180 ++++++++++++++++++ .../Views/Dialogs/DeleteSetupView.axaml.cs | 20 ++ 10 files changed, 709 insertions(+), 17 deletions(-) create mode 100644 DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs create mode 100644 DiscordChatExporter.Gui/ViewModels/Dialogs/DeleteSetupViewModel.cs create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml.cs diff --git a/DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs b/DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs new file mode 100644 index 00000000..186afaee --- /dev/null +++ b/DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Attributes; +using CliFx.Infrastructure; +using DiscordChatExporter.Cli.Commands.Base; +using DiscordChatExporter.Core.Discord; +using DiscordChatExporter.Core.Utils.Extensions; + +namespace DiscordChatExporter.Cli.Commands; + +[Command("deletemessages", Description = "Deletes messages from a channel.")] +public class DeleteMessagesCommand : DiscordCommandBase +{ + [CommandOption( + "channel", + 'c', + Description = "Channel ID. Note: You can only delete your own messages in DMs." + )] + public required Snowflake ChannelId { get; init; } + + [CommandOption( + "before", + Description = "Limit to messages sent before this date (formatted using the current culture)." + )] + public DateTimeOffset? Before { get; init; } + + [CommandOption( + "after", + Description = "Limit to messages sent after this date (formatted using the current culture)." + )] + public DateTimeOffset? After { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + await base.ExecuteAsync(console); + + var cancellationToken = console.RegisterCancellationHandler(); + + // Get current user + await console.Output.WriteLineAsync("Getting current user..."); + var currentUser = await Discord.GetCurrentUserAsync(cancellationToken); + await console.Output.WriteLineAsync($"Authenticated as: {currentUser.FullName}"); + await console.Output.WriteLineAsync(); + + // Resolve the channel + await console.Output.WriteLineAsync("Resolving channel..."); + var channel = await Discord.GetChannelAsync(ChannelId, cancellationToken); + + // Warning message + using (console.WithForegroundColor(ConsoleColor.Yellow)) + { + await console.Output.WriteLineAsync( + "WARNING: This will delete your messages from the channel." + ); + await console.Output.WriteLineAsync("Messages from other users will be skipped."); + } + + await console.Output.WriteLineAsync(); + await console.Output.WriteLineAsync($"Channel: {channel.Name}"); + + if (After is not null) + await console.Output.WriteLineAsync($"After: {After:g}"); + + if (Before is not null) + await console.Output.WriteLineAsync($"Before: {Before:g}"); + + await console.Output.WriteLineAsync(); + + // Count user's messages + await console.Output.WriteLineAsync("Counting your messages..."); + + var beforeSnowflake = Before?.Pipe(Snowflake.FromDate); + var afterSnowflake = After?.Pipe(Snowflake.FromDate); + + var userMessageIds = new List(); + await foreach ( + var message in Discord.GetMessagesAsync( + ChannelId, + afterSnowflake, + beforeSnowflake, + null, + cancellationToken + ) + ) + { + if (message.Author.Id == currentUser.Id) + { + userMessageIds.Add(message.Id); + } + } + + var totalUserMessages = userMessageIds.Count; + await console.Output.WriteLineAsync($"Found {totalUserMessages} of your messages"); + await console.Output.WriteLineAsync(); + + if (totalUserMessages == 0) + { + using (console.WithForegroundColor(ConsoleColor.Yellow)) + { + await console.Output.WriteLineAsync("No messages to delete."); + } + return; + } + + // Delete messages + await console.Output.WriteLineAsync("Deleting messages..."); + + var successCount = 0; + var failedCount = 0; + + foreach (var messageId in userMessageIds) + { + try + { + var deleted = await Discord.DeleteMessageAsync( + ChannelId, + messageId, + cancellationToken + ); + + if (deleted) + { + successCount++; + using (console.WithForegroundColor(ConsoleColor.Green)) + { + await console.Output.WriteAsync("\r"); + await console.Output.WriteAsync( + $"✓ Deleted {successCount} / {totalUserMessages} messages " + ); + } + } + else + { + failedCount++; + } + + // Discord's rate limit headers are handled automatically by DeleteMessageAsync + } + catch + { + failedCount++; + using (console.WithForegroundColor(ConsoleColor.Red)) + { + await console.Output.WriteAsync("\r"); + await console.Output.WriteAsync( + $"✗ Error | Deleted: {successCount} / {totalUserMessages}, Failed: {failedCount} " + ); + } + } + } + + // Clear the progress line + await console.Output.WriteLineAsync(); + await console.Output.WriteLineAsync(); + + // Report results + await console.Output.WriteLineAsync("Deletion complete!"); + + using (console.WithForegroundColor(ConsoleColor.Green)) + { + await console.Output.WriteLineAsync( + $"Successfully deleted: {successCount} / {totalUserMessages} message(s)" + ); + } + + if (failedCount > 0) + { + using (console.WithForegroundColor(ConsoleColor.Red)) + { + await console.Output.WriteLineAsync($"Failed to delete: {failedCount} message(s)"); + } + } + } +} diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index f435e282..d49a73f1 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -220,6 +220,12 @@ public class DiscordClient( return response?.Pipe(User.Parse); } + public async ValueTask GetCurrentUserAsync(CancellationToken cancellationToken = default) + { + var response = await GetJsonResponseAsync("users/@me", cancellationToken); + return User.Parse(response); + } + public async IAsyncEnumerable GetUserGuildsAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default ) @@ -820,4 +826,96 @@ public class DiscordClient( yield break; } } + + private async ValueTask DeleteResponseAsync( + string url, + TokenKind tokenKind, + CancellationToken cancellationToken = default + ) + { + return await Http.ResponseResiliencePipeline.ExecuteAsync( + async innerCancellationToken => + { + using var request = new HttpRequestMessage( + HttpMethod.Delete, + new Uri(_baseUri, url) + ); + + request.Headers.TryAddWithoutValidation( + "Authorization", + tokenKind == TokenKind.Bot ? $"Bot {token}" : token + ); + + var response = await Http.Client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + innerCancellationToken + ); + + if (rateLimitPreference.IsRespectedFor(tokenKind)) + { + var remainingRequestCount = response + .Headers.TryGetValue("X-RateLimit-Remaining") + ?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture)); + + var resetAfterDelay = response + .Headers.TryGetValue("X-RateLimit-Reset-After") + ?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture)) + .Pipe(TimeSpan.FromSeconds); + + if (remainingRequestCount <= 0 && resetAfterDelay is not null) + { + var delay = (resetAfterDelay.Value + TimeSpan.FromSeconds(1)).Clamp( + TimeSpan.Zero, + TimeSpan.FromSeconds(60) + ); + + await Task.Delay(delay, innerCancellationToken); + } + } + + return response; + }, + cancellationToken + ); + } + + private async ValueTask DeleteResponseAsync( + string url, + CancellationToken cancellationToken = default + ) => + await DeleteResponseAsync( + url, + await ResolveTokenKindAsync(cancellationToken), + cancellationToken + ); + + public async ValueTask DeleteMessageAsync( + Snowflake channelId, + Snowflake messageId, + CancellationToken cancellationToken = default + ) + { + using var response = await DeleteResponseAsync( + $"channels/{channelId}/messages/{messageId}", + cancellationToken + ); + + // 204 No Content = successfully deleted + if (response.StatusCode == HttpStatusCode.NoContent) + return true; + + // 403 Forbidden = not authorized to delete (e.g., someone else's message) + // 404 Not Found = message already deleted or doesn't exist + if ( + response.StatusCode == HttpStatusCode.Forbidden + || response.StatusCode == HttpStatusCode.NotFound + ) + return false; + + // For other errors, throw an exception + throw new DiscordChatExporterException( + $"Failed to delete message #{messageId} in channel #{channelId}: {response.StatusCode}" + ); + } } diff --git a/DiscordChatExporter.Gui/App.axaml.cs b/DiscordChatExporter.Gui/App.axaml.cs index 92640636..1c66489c 100644 --- a/DiscordChatExporter.Gui/App.axaml.cs +++ b/DiscordChatExporter.Gui/App.axaml.cs @@ -47,6 +47,7 @@ public class App : Application, IDisposable services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/DiscordChatExporter.Gui/Framework/ViewManager.cs b/DiscordChatExporter.Gui/Framework/ViewManager.cs index 5ae9e1a4..13a15842 100644 --- a/DiscordChatExporter.Gui/Framework/ViewManager.cs +++ b/DiscordChatExporter.Gui/Framework/ViewManager.cs @@ -17,6 +17,7 @@ public partial class ViewManager MainViewModel => new MainView(), DashboardViewModel => new DashboardView(), ExportSetupViewModel => new ExportSetupView(), + DeleteSetupViewModel => new DeleteSetupView(), MessageBoxViewModel => new MessageBoxView(), SettingsViewModel => new SettingsView(), _ => null, diff --git a/DiscordChatExporter.Gui/Framework/ViewModelManager.cs b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs index aaed957d..db7f80c0 100644 --- a/DiscordChatExporter.Gui/Framework/ViewModelManager.cs +++ b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs @@ -28,6 +28,19 @@ public class ViewModelManager(IServiceProvider services) return viewModel; } + public DeleteSetupViewModel GetDeleteSetupViewModel( + Guild guild, + IReadOnlyList channels + ) + { + var viewModel = services.GetRequiredService(); + + viewModel.Guild = guild; + viewModel.Channels = channels; + + return viewModel; + } + public MessageBoxViewModel GetMessageBoxViewModel( string title, string message, diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index 542c9526..121edd22 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -56,7 +56,11 @@ public partial class DashboardViewModel : ViewModelBase ), SelectedChannels.WatchProperty( o => o.Count, - _ => ExportCommand.NotifyCanExecuteChanged() + _ => + { + ExportCommand.NotifyCanExecuteChanged(); + DeleteMessagesCommand.NotifyCanExecuteChanged(); + } ) ); } @@ -66,6 +70,7 @@ public partial class DashboardViewModel : ViewModelBase [NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))] [NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))] [NotifyCanExecuteChangedFor(nameof(ExportCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteMessagesCommand))] public partial bool IsBusy { get; set; } public LocalizationManager LocalizationManager { get; } @@ -324,6 +329,125 @@ public partial class DashboardViewModel : ViewModelBase } } + private bool CanDeleteMessages() => + !IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels.Any(); + + [RelayCommand(CanExecute = nameof(CanDeleteMessages))] + private async Task DeleteMessagesAsync() + { + IsBusy = true; + + try + { + if (_discord is null || SelectedGuild is null || !SelectedChannels.Any()) + return; + + var currentUser = await _discord.GetCurrentUserAsync(); + + var dialog = _viewModelManager.GetDeleteSetupViewModel( + SelectedGuild, + SelectedChannels.Select(c => c.Channel).ToArray() + ); + + if (await _dialogManager.ShowDialogAsync(dialog) != true) + return; + + var channelProgressPairs = dialog + .Channels!.Select(c => new { Channel = c, Progress = _progressMuxer.CreateInput() }) + .ToArray(); + + var totalDeletedCount = 0; + var totalFailedCount = 0; + + await Parallel.ForEachAsync( + channelProgressPairs, + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit), + }, + async (pair, cancellationToken) => + { + var channel = pair.Channel; + var progress = pair.Progress; + + try + { + var userMessageIds = new List(); + await foreach ( + var message in _discord.GetMessagesAsync( + channel.Id, + dialog.After?.Pipe(Snowflake.FromDate), + dialog.Before?.Pipe(Snowflake.FromDate), + progress, + cancellationToken + ) + ) + { + if (message.Author.Id == currentUser.Id) + userMessageIds.Add(message.Id); + } + + if (userMessageIds.Count == 0) + return; + + var successCount = 0; + var failedCount = 0; + + foreach (var messageId in userMessageIds) + { + try + { + var deleted = await _discord.DeleteMessageAsync( + channel.Id, + messageId, + cancellationToken + ); + + if (deleted) + successCount++; + else + failedCount++; + } + catch (DiscordChatExporterException ex) when (!ex.IsFatal) + { + failedCount++; + _snackbarManager.Notify(ex.Message.TrimEnd('.')); + } + } + + Interlocked.Add(ref totalDeletedCount, successCount); + Interlocked.Add(ref totalFailedCount, failedCount); + } + catch (DiscordChatExporterException ex) when (!ex.IsFatal) + { + _snackbarManager.Notify(ex.Message.TrimEnd('.')); + } + finally + { + progress.ReportCompletion(); + } + } + ); + + _snackbarManager.Notify( + $"Deleted {totalDeletedCount} message(s), {totalFailedCount} failed" + ); + } + catch (Exception ex) + { + var dialog = _viewModelManager.GetMessageBoxViewModel( + "Error deleting messages", + ex.ToString() + ); + + await _dialogManager.ShowDialogAsync(dialog); + } + finally + { + IsBusy = false; + } + } + protected override void Dispose(bool disposing) { if (disposing) diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/DeleteSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/DeleteSetupViewModel.cs new file mode 100644 index 00000000..9ed75f18 --- /dev/null +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/DeleteSetupViewModel.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Gui.Framework; + +namespace DiscordChatExporter.Gui.ViewModels.Dialogs; + +public partial class DeleteSetupViewModel : DialogViewModelBase +{ + [ObservableProperty] + public partial Guild? Guild { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSingleChannel))] + public partial IReadOnlyList? Channels { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAfterDateSet))] + [NotifyPropertyChangedFor(nameof(After))] + public partial DateTimeOffset? AfterDate { get; set; } + + [ObservableProperty] + public partial TimeSpan? AfterTime { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBeforeDateSet))] + [NotifyPropertyChangedFor(nameof(Before))] + public partial DateTimeOffset? BeforeDate { get; set; } + + [ObservableProperty] + public partial TimeSpan? BeforeTime { get; set; } + + public bool IsSingleChannel => Channels?.Count == 1; + + public bool IsAfterDateSet => AfterDate is not null; + + public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero); + + public bool IsBeforeDateSet => BeforeDate is not null; + + public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero); + + [RelayCommand] + private void Confirm() + { + Close(true); + } + + [RelayCommand] + private void Cancel() + { + Close(null); + } +} diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml index cd3ea111..d84af318 100644 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml @@ -284,24 +284,47 @@ - - + Margin="32,24" + Orientation="Horizontal" + Spacing="16"> + + + + + + diff --git a/DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml b/DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml new file mode 100644 index 00000000..33b4327e --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +