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.
This commit is contained in:
primetime43 2026-05-15 03:02:14 -04:00
parent 6258394fc0
commit c38953d868
10 changed files with 709 additions and 17 deletions

View file

@ -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<Snowflake>();
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)");
}
}
}
}

View file

@ -220,6 +220,12 @@ public class DiscordClient(
return response?.Pipe(User.Parse);
}
public async ValueTask<User> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
var response = await GetJsonResponseAsync("users/@me", cancellationToken);
return User.Parse(response);
}
public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
@ -820,4 +826,96 @@ public class DiscordClient(
yield break;
}
}
private async ValueTask<HttpResponseMessage> 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<HttpResponseMessage> DeleteResponseAsync(
string url,
CancellationToken cancellationToken = default
) =>
await DeleteResponseAsync(
url,
await ResolveTokenKindAsync(cancellationToken),
cancellationToken
);
public async ValueTask<bool> 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}"
);
}
}

View file

@ -47,6 +47,7 @@ public class App : Application, IDisposable
services.AddTransient<MainViewModel>();
services.AddTransient<DashboardViewModel>();
services.AddTransient<ExportSetupViewModel>();
services.AddTransient<DeleteSetupViewModel>();
services.AddTransient<MessageBoxViewModel>();
services.AddTransient<SettingsViewModel>();

View file

@ -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,

View file

@ -28,6 +28,19 @@ public class ViewModelManager(IServiceProvider services)
return viewModel;
}
public DeleteSetupViewModel GetDeleteSetupViewModel(
Guild guild,
IReadOnlyList<Channel> channels
)
{
var viewModel = services.GetRequiredService<DeleteSetupViewModel>();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
public MessageBoxViewModel GetMessageBoxViewModel(
string title,
string message,

View file

@ -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<Snowflake>();
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)

View file

@ -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<Channel>? 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);
}
}

View file

@ -284,24 +284,47 @@
</ScrollViewer>
</Panel>
<!-- Export button -->
<Button
Width="56"
Height="56"
Margin="32,24"
Padding="0"
<!-- Action buttons -->
<StackPanel
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Background="{DynamicResource MaterialSecondaryMidBrush}"
Command="{Binding ExportCommand}"
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}">
<materialIcons:MaterialIcon
Width="32"
Height="32"
Kind="Download" />
</Button>
Margin="32,24"
Orientation="Horizontal"
Spacing="16">
<!-- Delete button -->
<Button
Width="56"
Height="56"
Padding="0"
Background="#FFE74C3C"
Command="{Binding DeleteMessagesCommand}"
Foreground="White"
IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}"
ToolTip.Tip="Delete messages from selected channels">
<materialIcons:MaterialIcon
Width="32"
Height="32"
Kind="Delete" />
</Button>
<!-- Export button -->
<Button
Width="56"
Height="56"
Padding="0"
Background="{DynamicResource MaterialSecondaryMidBrush}"
Command="{Binding ExportCommand}"
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}"
ToolTip.Tip="Export selected channels">
<materialIcons:MaterialIcon
Width="32"
Height="32"
Kind="Download" />
</Button>
</StackPanel>
</Panel>
</DockPanel>
</UserControl>

View file

@ -0,0 +1,180 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.DeleteSetupView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
x:Name="UserControl"
Width="380"
x:DataType="dialogs:DeleteSetupViewModel"
Loaded="UserControl_OnLoaded">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Guild/channel info -->
<Grid
Grid.Row="0"
Margin="16"
ColumnDefinitions="Auto,*">
<!-- Guild icon -->
<Ellipse
Grid.Column="0"
Width="32"
Height="32">
<Ellipse.Fill>
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding Guild.IconUrl}" />
</Ellipse.Fill>
</Ellipse>
<!-- Channel count (for multiple channels) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
IsVisible="{Binding !IsSingleChannel}"
TextTrimming="CharacterEllipsis">
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
<Run Text="channels selected" />
</TextBlock>
<!-- Category and channel name (for single channel) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
IsVisible="{Binding IsSingleChannel}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
<TextBlock IsVisible="{Binding !!Channels[0].Parent}">
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
<Run Text="/" />
</TextBlock>
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock>
</Grid>
<Border
Grid.Row="1"
Padding="0,8"
BorderBrush="{DynamicResource MaterialDividerBrush}"
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Orientation="Vertical">
<!-- Warning message -->
<Border
Margin="16,8"
Padding="12"
Background="#33FFA500"
BorderBrush="#FFFFA500"
BorderThickness="2"
CornerRadius="4">
<TextBlock
FontSize="13"
FontWeight="SemiBold"
Foreground="#FFFFA500"
TextWrapping="Wrap">
<Run Text="⚠ WARNING: This will permanently delete messages from the channel." />
<LineBreak />
<Run FontWeight="Normal" Text="You can only delete your own messages. Messages from other users will be skipped." />
</TextBlock>
</Border>
<!-- Date limits -->
<Grid ColumnDefinitions="*,*" RowDefinitions="*,*">
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="After (date)"
SelectedDate="{Binding AfterDate}"
ToolTip.Tip="Only delete messages sent after this date">
<DatePicker.Styles>
<Style Selector="DatePicker">
<Style Selector="^ /template/ TextBox#DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</DatePicker.Styles>
</DatePicker>
<DatePicker
Grid.Row="0"
Grid.Column="1"
Margin="8,8,16,8"
materialAssists:TextFieldAssist.Label="Before (date)"
SelectedDate="{Binding BeforeDate}"
ToolTip.Tip="Only delete messages sent before this date">
<DatePicker.Styles>
<Style Selector="DatePicker">
<Style Selector="^ /template/ TextBox#DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</DatePicker.Styles>
</DatePicker>
<!-- Time limits -->
<TimePicker
Grid.Row="1"
Grid.Column="0"
Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="After (time)"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime}"
ToolTip.Tip="Only delete messages sent after this time">
<TimePicker.Styles>
<Style Selector="TimePicker">
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</TimePicker.Styles>
</TimePicker>
<TimePicker
Grid.Row="1"
Grid.Column="1"
Margin="8,8,16,8"
materialAssists:TextFieldAssist.Label="Before (time)"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime}"
ToolTip.Tip="Only delete messages sent before this time">
<TimePicker.Styles>
<Style Selector="TimePicker">
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</TimePicker.Styles>
</TimePicker>
</Grid>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Buttons -->
<Grid
Grid.Row="2"
Margin="16"
ColumnDefinitions="*,Auto,Auto">
<Button
Grid.Column="1"
Command="{Binding ConfirmCommand}"
Content="DELETE"
IsDefault="True"
Theme="{DynamicResource MaterialOutlineButton}" />
<Button
Grid.Column="2"
Margin="16,0,0,0"
Command="{Binding CancelCommand}"
Content="CANCEL"
IsCancel="True"
Theme="{DynamicResource MaterialOutlineButton}" />
</Grid>
</Grid>
</UserControl>

View file

@ -0,0 +1,20 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DiscordChatExporter.Gui.Views.Dialogs;
public partial class DeleteSetupView : UserControl
{
public DeleteSetupView()
{
InitializeComponent();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args)
{
// Nothing to do on load for now
}
}