From ca751ed76449d47a1d2c4a32ca8f4a1c16f8c201 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 22 Oct 2026 21:42:03 +1000 Subject: [PATCH 01/14] chore(editorconfig): update formatting rules for consistency --- .editorconfig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.editorconfig b/.editorconfig index ba610bc3..ffd3780a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,10 +1,21 @@ root = true +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 4 [*.cs] # Unused private members dotnet_diagnostic.IDE0051.severity = warning dotnet_diagnostic.IDE0052.severity = warning +tab_width = 4 # Discord.Net discovers command handlers via reflection โ€” they appear unused to static analysis [DiscordBot/Modules/**.cs] dotnet_diagnostic.IDE0051.severity = none + +[*.json] +indent_size = 2 From e92ae883e01c93797ba4ec2db6775e969cd6ba8b Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 16:03:13 +1000 Subject: [PATCH 02/14] feat(settings): add IntroductionWatch settings and prompt schedule - New IntroductionWatch section (Enabled=false, DelayMinutes=5) with a validation warning when enabled and DelayMinutes < 1 - IntroductionPromptSchedule: thread-safe trailing debounce, delay clamped to at least 1 minute --- .../IntroductionPromptScheduleTests.cs | 76 +++++++++++++++++++ .../Settings/BotSettingsValidationTests.cs | 18 +++++ .../Server/IntroductionPromptSchedule.cs | 52 +++++++++++++ DiscordBot/Settings/Deserialized/Settings.cs | 10 +++ DiscordBot/Settings/Settings.example.json | 5 ++ 5 files changed, 161 insertions(+) create mode 100644 DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs create mode 100644 DiscordBot/Services/Server/IntroductionPromptSchedule.cs diff --git a/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs b/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs new file mode 100644 index 00000000..22b008e2 --- /dev/null +++ b/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs @@ -0,0 +1,76 @@ +using DiscordBot.Services.Server; + +namespace DiscordBot.Tests.Services; + +public class IntroductionPromptScheduleTests +{ + private static readonly DateTime Start = new(2026, 10, 25, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void NotDue_BeforeAnyTouch() + { + var schedule = new IntroductionPromptSchedule(5); + Assert.False(schedule.IsDue(Start.AddDays(1))); + Assert.Null(schedule.DueUtc); + } + + [Fact] + public void NotDue_BeforeDelayPasses() + { + var schedule = new IntroductionPromptSchedule(5); + schedule.Touch(Start); + Assert.False(schedule.IsDue(Start.AddMinutes(5).AddSeconds(-1))); + } + + [Fact] + public void Due_OnceDelayHasPassed() + { + var schedule = new IntroductionPromptSchedule(5); + schedule.Touch(Start); + Assert.True(schedule.IsDue(Start.AddMinutes(5))); + Assert.True(schedule.IsDue(Start.AddMinutes(6))); + } + + [Fact] + public void SecondTouch_PushesDueTimeBack() + { + var schedule = new IntroductionPromptSchedule(5); + schedule.Touch(Start); + schedule.Touch(Start.AddMinutes(3)); + Assert.False(schedule.IsDue(Start.AddMinutes(5))); + Assert.True(schedule.IsDue(Start.AddMinutes(8))); + Assert.Equal(Start.AddMinutes(8), schedule.DueUtc); + } + + [Fact] + public void Clear_ResetsSchedule() + { + var schedule = new IntroductionPromptSchedule(5); + schedule.Touch(Start); + schedule.Clear(); + Assert.False(schedule.IsDue(Start.AddMinutes(10))); + Assert.Null(schedule.DueUtc); + } + + [Theory] + [InlineData(0)] + [InlineData(-3)] + public void Delay_ClampedToAtLeastOneMinute(int delayMinutes) + { + var schedule = new IntroductionPromptSchedule(delayMinutes); + Assert.Equal(TimeSpan.FromMinutes(1), schedule.Delay); + } + + [Fact] + public void TryTakeDue_ClearsOnlyWhenDue() + { + var schedule = new IntroductionPromptSchedule(5); + schedule.Touch(Start); + Assert.False(schedule.TryTakeDue(Start.AddMinutes(4))); + Assert.NotNull(schedule.DueUtc); + + Assert.True(schedule.TryTakeDue(Start.AddMinutes(5))); + Assert.Null(schedule.DueUtc); + Assert.False(schedule.TryTakeDue(Start.AddMinutes(6))); + } +} diff --git a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs index daa92f26..0d52e7f2 100644 --- a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs +++ b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs @@ -126,4 +126,22 @@ public void UnityHelpEnabled_MissingTagResolved_Warning() var (_, warnings) = settings.Validate(); Assert.Contains(warnings, w => w.Contains("TagResolved")); } + + [Fact] + public void IntroductionWatchEnabled_ZeroDelay_Warning() + { + var settings = CreateValid(); + settings.IntroductionWatch = new IntroductionWatchSettings { Enabled = true, DelayMinutes = 0 }; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("IntroductionWatch.DelayMinutes")); + } + + [Fact] + public void IntroductionWatchDisabled_ZeroDelay_NoWarning() + { + var settings = CreateValid(); + settings.IntroductionWatch = new IntroductionWatchSettings { Enabled = false, DelayMinutes = 0 }; + var (_, warnings) = settings.Validate(); + Assert.DoesNotContain(warnings, w => w.Contains("IntroductionWatch")); + } } diff --git a/DiscordBot/Services/Server/IntroductionPromptSchedule.cs b/DiscordBot/Services/Server/IntroductionPromptSchedule.cs new file mode 100644 index 00000000..53287bf3 --- /dev/null +++ b/DiscordBot/Services/Server/IntroductionPromptSchedule.cs @@ -0,0 +1,52 @@ +namespace DiscordBot.Services.Server; + +/// +/// Trailing debounce for the Introductions prompt. Each pushes the due time +/// back to now + . Thread-safe; all times are UTC. +/// +public sealed class IntroductionPromptSchedule +{ + private readonly object _lock = new(); + private DateTime? _dueUtc; + + public TimeSpan Delay { get; } + + public IntroductionPromptSchedule(int delayMinutes) + { + Delay = TimeSpan.FromMinutes(Math.Max(1, delayMinutes)); + } + + public DateTime? DueUtc + { + get { lock (_lock) return _dueUtc; } + } + + public void Touch(DateTime nowUtc) + { + lock (_lock) _dueUtc = nowUtc + Delay; + } + + public bool IsDue(DateTime nowUtc) + { + lock (_lock) return _dueUtc is { } due && nowUtc >= due; + } + + /// + /// If the schedule is due, clears it and returns true. Checking and clearing in one step means + /// a that lands while a prompt is being posted is not lost. + /// + public bool TryTakeDue(DateTime nowUtc) + { + lock (_lock) + { + if (_dueUtc is not { } due || nowUtc < due) return false; + _dueUtc = null; + return true; + } + } + + public void Clear() + { + lock (_lock) _dueUtc = null; + } +} diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 6e9ea102..2a90c571 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -20,6 +20,7 @@ public class BotSettings public UnityHelpSettings UnityHelp { get; set; } = new(); public CasinoSettings Casino { get; set; } = new(); public BirthdaySettings Birthday { get; set; } = new(); + public IntroductionWatchSettings IntroductionWatch { get; set; } = new(); public ApiKeySettings ApiKeys { get; set; } = new(); public FunCommandSettings FunCommands { get; set; } = new(); @@ -51,6 +52,9 @@ public class BotSettings if (Birthday.Enabled) ValidateChannel(warnings, Channels.BirthdayAnnouncement, nameof(Channels.BirthdayAnnouncement)); + if (IntroductionWatch.Enabled && IntroductionWatch.DelayMinutes < 1) + warnings.Add("IntroductionWatch.DelayMinutes is less than 1 โ€” 1 minute will be used"); + if (Recruitment.Enabled) { ValidateChannel(warnings, Channels.Recruitment, nameof(Channels.Recruitment)); @@ -147,6 +151,12 @@ public class BirthdaySettings public int CheckIntervalMinutes { get; set; } = 240; } +public class IntroductionWatchSettings +{ + public bool Enabled { get; set; } = false; + public int DelayMinutes { get; set; } = 5; +} + public class ApiKeySettings { public string Weather { get; set; } = string.Empty; diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index 3871ab2b..50f99abf 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -62,6 +62,11 @@ "CheckIntervalMinutes": 240 }, + "IntroductionWatch": { + "Enabled": false, + "DelayMinutes": 5 + }, + "ApiKeys": { "Weather": "", "Flight": "", From 76413d3d239ccc8753b1b06af3568fd7b0978eb1 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 16:04:00 +1000 Subject: [PATCH 03/14] feat(server): add IntroductionWatcherService say-hello prompt - Posts a Components V2 prompt in Introductions once the channel has been quiet for DelayMinutes after a human message, then deletes the old prompt - Skips posting when the latest message is already the prompt - On startup finds the existing prompt in recent history and removes older duplicates - Prompt is sent with no allowed mentions; off unless IntroductionWatch.Enabled --- DiscordBot/Program.cs | 2 + .../Server/IntroductionWatcherService.cs | 181 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 DiscordBot/Services/Server/IntroductionWatcherService.cs diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index b3bf6eb0..336c0c4b 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -84,6 +84,7 @@ private async Task MainAsync() _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); @@ -122,6 +123,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/DiscordBot/Services/Server/IntroductionWatcherService.cs b/DiscordBot/Services/Server/IntroductionWatcherService.cs new file mode 100644 index 00000000..fe96b00b --- /dev/null +++ b/DiscordBot/Services/Server/IntroductionWatcherService.cs @@ -0,0 +1,181 @@ +using System.Net; +using Discord.Net; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Server; + +/// +/// Keeps a short "say hello" prompt as the latest message in the Introductions channel. +/// After a human posts, waits until the channel has been quiet for DelayMinutes, then posts a +/// new prompt and deletes the previous one. +/// +public class IntroductionWatcherService +{ + private const string ServiceName = "IntroductionWatcherService"; + private const int HistoryScanLimit = 50; + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); + private static readonly Color AccentColour = new(0x12D687); + + private readonly DiscordSocketClient _client; + private readonly ILoggingService _loggingService; + private readonly CancellationToken _shutdownToken; + private readonly ITextChannel _channel = null!; + private readonly IntroductionPromptSchedule _schedule = null!; + + // Only touched from the background loop. + private ulong? _promptId; + + public IntroductionWatcherService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService, + CancellationTokenSource cts) + { + _client = client; + _loggingService = loggingService; + _shutdownToken = cts.Token; + + if (!settings.IntroductionWatch.Enabled) + { + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.IntroductionWatch.Enabled)); + return; + } + + if (_client.GetChannel(settings.Channels.Introduction?.Id ?? 0) is not ITextChannel channel) + { + LoggingService.LogToConsole($"[{ServiceName}] Introduction channel not found.", LogSeverity.Error); + return; + } + + _channel = channel; + _schedule = new IntroductionPromptSchedule(settings.IntroductionWatch.DelayMinutes); + + _client.MessageReceived += EventGuard.Guarded(OnMessageReceived, nameof(OnMessageReceived)); + Task.Run(RunAsync); + + LoggingService.LogServiceEnabled(ServiceName); + } + + public static MessageComponent BuildPrompt() => + new ComponentBuilderV2() + .WithContainer(new ContainerBuilder() + .WithAccentColor(AccentColour) + .WithTextDisplay("### ๐Ÿ‘‹ Your turn to say hello\n" + + "New here? Tell us a little about yourself!\n" + + "**Try sharing:** your name or nickname, interests, and what brought you here.") + .WithSeparator(SeparatorSpacingSize.Small, true) + .WithTextDisplay("**Keep it an introduction.** No \"DM me\" requests, ads, or personal contact details.\n" + + "-# A few lines is plenty. Share only what you're comfortable sharing.")) + .Build(); + + private Task OnMessageReceived(SocketMessage message) + { + if (IsHumanIntroMessage(message)) + _schedule.Touch(DateTime.UtcNow); + return Task.CompletedTask; + } + + private bool IsHumanIntroMessage(IMessage message) => + message.Channel.Id == _channel.Id + && message is IUserMessage + && message.Type is MessageType.Default or MessageType.Reply + && !message.Author.IsUserBotOrWebhook(); + + private bool IsPrompt(IMessage message) => + message.Author.Id == _client.CurrentUser.Id + && message.Flags is { } flags + && flags.HasFlag(MessageFlags.ComponentsV2); + + private async Task RunAsync() + { + try + { + await RecoverFromHistoryAsync(); + + while (!_shutdownToken.IsCancellationRequested) + { + await Task.Delay(PollInterval, _shutdownToken); + try + { + if (_schedule.TryTakeDue(DateTime.UtcNow)) + await PostPromptAsync(); + } + catch (Exception e) when (e is not OperationCanceledException) + { + await _loggingService.LogChannelAndFile( + $"[{ServiceName}] Failed to post the introduction prompt: {e.Message}", ExtendedLogSeverity.Warning); + } + } + } + catch (OperationCanceledException) { } + } + + /// + /// Finds the existing prompt after a restart, removes any older duplicates, and schedules a + /// new prompt if there is none or a human posted after it. + /// + private async Task RecoverFromHistoryAsync() + { + try + { + var messages = (await _channel.GetMessagesAsync(HistoryScanLimit).FlattenAsync()) + .OrderByDescending(m => m.Id) + .ToList(); + + var prompts = messages.Where(IsPrompt).ToList(); + var newestPrompt = prompts.FirstOrDefault(); + + if (newestPrompt != null) + { + _promptId = newestPrompt.Id; + foreach (var stale in prompts.Skip(1)) + await DeleteMessageAsync(stale.Id); + } + + var humanAfterPrompt = messages.Any(m => IsHumanIntroMessage(m) && (newestPrompt == null || m.Id > newestPrompt.Id)); + if (newestPrompt == null || humanAfterPrompt) + _schedule.Touch(DateTime.UtcNow); + } + catch (Exception e) when (e is not OperationCanceledException) + { + await _loggingService.LogChannelAndFile( + $"[{ServiceName}] Could not read Introductions history on startup: {e.Message}", ExtendedLogSeverity.Warning); + } + } + + private async Task PostPromptAsync() + { + // Skip if the prompt is already the latest message, e.g. the human message that started the timer was deleted. + var latest = (await _channel.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); + if (latest != null && IsPrompt(latest)) + { + _promptId ??= latest.Id; + return; + } + + var sent = await _channel.SendMessageAsync( + components: BuildPrompt(), + allowedMentions: AllowedMentions.None, + flags: MessageFlags.ComponentsV2); + + var previous = _promptId; + _promptId = sent.Id; + if (previous is { } previousId && previousId != sent.Id) + await DeleteMessageAsync(previousId); + } + + private async Task DeleteMessageAsync(ulong messageId) + { + try + { + await _channel.DeleteMessageAsync(messageId); + } + catch (HttpException e) when (e.HttpCode == HttpStatusCode.NotFound) + { + // Already deleted. + } + catch (Exception e) + { + await _loggingService.LogChannelAndFile( + $"[{ServiceName}] Failed to delete old introduction prompt {messageId}: {e.Message}", ExtendedLogSeverity.Warning); + } + } +} From 9ecc3906c16851e36882430bd50d94ee965ff623 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 16:04:14 +1000 Subject: [PATCH 04/14] feat(k8s): enable introduction prompt in dev and document it - Dev configmap enables IntroductionWatch with a 1 minute delay - Add Introduction Prompt row to docs/features.md --- docs/features.md | 1 + k8s/dev/bot-config.yaml | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/features.md b/docs/features.md index 97885561..ad97acf7 100644 --- a/docs/features.md +++ b/docs/features.md @@ -29,6 +29,7 @@ | **Embed Builder** | Generate embeds from messages or hastebin URLs | `EmbedModule` | `EmbedParsingService` | Feature | | **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday show [count] [user]` lists upcoming birthdays or a specific member's birthday, `/bday set`, `/bday del`, the **View Birthday**, **Set Birthday**, and **Remove Birthday** user context-menu commands โ€” removal asks for confirmation via buttons, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | | **Recruitment** | Configurable recruitment workflow (toggleable) | โ€” | `RecruitService` | Feature | +| **Introduction Prompt** | Keeps a short "say hello" prompt as the latest message in Introductions: after a member posts and the channel has been quiet for `IntroductionWatch.DelayMinutes`, posts a new prompt (no pings) and deletes the old one (toggleable via `IntroductionWatch.Enabled`, off by default). Needs View Channel, Send Messages and Read Message History in Introductions | โ€” | `IntroductionWatcherService` | Feature | | **Release Feeds** | RSS feed parsing and Unity release-notes tracking | โ€” | `FeedService`, `ReleaseNotesParser` | Feature | | **@everyone Scold** | Warns users who use `@everyone`/`@here` without permission | โ€” | `EveryoneScoldService` | Feature | | **Miku** | Playful auto-reply when Hatsune Miku is mentioned | โ€” | `MikuService` | Feature | diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index 1c9f60e7..1c02e72e 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -66,6 +66,11 @@ data: "TipImageDirectory": "tips" }, + "IntroductionWatch": { + "Enabled": true, + "DelayMinutes": 1 + }, + "FunCommands": { "SlapObjectsTable": "Settings/udc-slap.txt", "SlapChoices": [ From ba10990841132b2a3ff35bc206246d17c9b8a017 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 16:51:27 +1000 Subject: [PATCH 05/14] feat(introduction): enhance introduction prompt with channel links and updated messaging --- .../Server/IntroductionWatcherService.cs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/DiscordBot/Services/Server/IntroductionWatcherService.cs b/DiscordBot/Services/Server/IntroductionWatcherService.cs index fe96b00b..1c492068 100644 --- a/DiscordBot/Services/Server/IntroductionWatcherService.cs +++ b/DiscordBot/Services/Server/IntroductionWatcherService.cs @@ -18,6 +18,7 @@ public class IntroductionWatcherService private static readonly Color AccentColour = new(0x12D687); private readonly DiscordSocketClient _client; + private readonly BotSettings _settings; private readonly ILoggingService _loggingService; private readonly CancellationToken _shutdownToken; private readonly ITextChannel _channel = null!; @@ -30,6 +31,7 @@ public IntroductionWatcherService(DiscordSocketClient client, BotSettings settin CancellationTokenSource cts) { _client = client; + _settings = settings; _loggingService = loggingService; _shutdownToken = cts.Token; @@ -54,18 +56,24 @@ public IntroductionWatcherService(DiscordSocketClient client, BotSettings settin LoggingService.LogServiceEnabled(ServiceName); } - public static MessageComponent BuildPrompt() => + public static MessageComponent BuildPrompt(ChannelInfo? rulesChannel, ChannelInfo? helpChannel) => new ComponentBuilderV2() .WithContainer(new ContainerBuilder() .WithAccentColor(AccentColour) - .WithTextDisplay("### ๐Ÿ‘‹ Your turn to say hello\n" + - "New here? Tell us a little about yourself!\n" + - "**Try sharing:** your name or nickname, interests, and what brought you here.") + .WithTextDisplay("### ๐Ÿ‘‹ New here? Introduce yourself!\n" + + "We'd love to know a little about you.\n" + + "**Some ideas:** a nickname, what you're making or learning, and what brought you here.") .WithSeparator(SeparatorSpacingSize.Small, true) - .WithTextDisplay("**Keep it an introduction.** No \"DM me\" requests, ads, or personal contact details.\n" + - "-# A few lines is plenty. Share only what you're comfortable sharing.")) + .WithTextDisplay("**Before you post**\n" + + $"- Keep it to an introduction. Questions go in {ChannelLink(helpChannel, "the help channels")}.\n" + + "- No ads, \"DM me\" requests, or personal contact details.\n" + + "-# A few lines is plenty, and only share what you're comfortable with. " + + $"Full rules: {ChannelLink(rulesChannel, "#rules")}")) .Build(); + private static string ChannelLink(ChannelInfo? channel, string fallback) => + channel is { Id: not 0 } ? $"<#{channel.Id}>" : fallback; + private Task OnMessageReceived(SocketMessage message) { if (IsHumanIntroMessage(message)) @@ -152,7 +160,7 @@ private async Task PostPromptAsync() } var sent = await _channel.SendMessageAsync( - components: BuildPrompt(), + components: BuildPrompt(_settings.Channels.Rules, _settings.Channels.GenericHelp), allowedMentions: AllowedMentions.None, flags: MessageFlags.ComponentsV2); From 7f642178ee24673da3a1f93f12d4df6f95888641 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 16:53:01 +1000 Subject: [PATCH 06/14] feat(vscode): add VSCode configuration files and update tasks for .NET 8 support Fixes some issues around running tasks through vscode and versions being required. Also updates the unsupported netcoreapp2.1 to 8.0 in launch config. Also use integrated terminal so that colours can be seen in terminal --- .vscode/extensions.json | 5 ++ .vscode/launch.json | 54 ++++++------ .vscode/tasks.json | 165 ++++++++++++++++++++++++++--------- DiscordBot/DiscordBot.csproj | 3 +- 4 files changed, 160 insertions(+), 67 deletions(-) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..067ec096 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "ms-dotnettools.csharp" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 12e91de6..ed54a3fb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,27 +1,29 @@ { - // Use IntelliSense to find out which attributes exist for C# debugging - // Use hover for the description of the existing attributes - // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md - "version": "0.2.0", - "configurations": [ - { - "name": ".NET Core Launch (console)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/DiscordBot/bin/Debug/netcoreapp2.1/DiscordBot.dll", - "args": [], - "cwd": "${workspaceFolder}/DiscordBot", - // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console - "console": "internalConsole", - "stopAtEntry": false - }, - { - "name": ".NET Core Attach", - "type": "coreclr", - "request": "attach", - "processId": "${command:pickProcess}" - } - ] -} \ No newline at end of file + // Requires the C# extension (ms-dotnettools.csharp), recommended in extensions.json. + // See tasks.json for why the bot is built for net8.0 and run with DOTNET_ROLL_FORWARD. + "version": "0.2.0", + "configurations": [ + { + "name": "Launch bot", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build and check settings", + "program": "${workspaceFolder}/DiscordBot/bin/Debug/net8.0/DiscordBot.dll", + "args": [], + // The bot reads Settings/, Assets/ and SERVER/ relative to this folder. + "cwd": "${workspaceFolder}/DiscordBot", + "env": { + "DOTNET_ROLL_FORWARD": "Major" + }, + // The integrated terminal shows the bot's coloured log output. + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": "Attach to bot", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index adf4d422..c152a6e1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,42 +1,127 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "publish", - "command": "dotnet", - "type": "process", - "args": [ - "publish", - "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "watch", - "command": "dotnet", - "type": "process", - "args": [ - "watch", - "run", - "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" + // Tasks build and run net8.0 only (the target used by Docker and CI), so any .NET 8+ SDK works. + // DOTNET_ROLL_FORWARD=Major lets the net8.0 build run on a newer runtime when .NET 8 isn't installed. + // The bot reads Settings/, Assets/ and SERVER/ relative to DiscordBot/, so run tasks use that as cwd. + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/DiscordBot/DiscordBot.csproj", + "--framework", + "net8.0", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile" + }, + { + "label": "check settings", + "detail": "Fails with a hint if DiscordBot/Settings/Settings.json is missing", + "type": "shell", + "command": "test -f Settings/Settings.json || { echo 'DiscordBot/Settings/Settings.json is missing. Copy DiscordBot/Settings/Settings.example.json to Settings.json and fill in Token and DbConnectionString.'; exit 1; }", + "windows": { + "command": "if (-not (Test-Path Settings/Settings.json)) { Write-Host 'DiscordBot/Settings/Settings.json is missing. Copy DiscordBot/Settings/Settings.example.json to Settings.json and fill in Token and DbConnectionString.'; exit 1 }", + "options": { + "cwd": "${workspaceFolder}/DiscordBot", + "shell": { + "executable": "powershell.exe", + "args": ["-NoProfile", "-Command"] + } } - ] -} \ No newline at end of file + }, + "options": { + "cwd": "${workspaceFolder}/DiscordBot" + }, + "presentation": { + "reveal": "silent", + "revealProblems": "never" + }, + "problemMatcher": [] + }, + { + "label": "build and check settings", + "detail": "Used by the launch configuration", + "dependsOn": ["check settings", "build"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "test", + "command": "dotnet", + "type": "process", + "args": [ + "test", + "${workspaceFolder}/DiscordBot.Tests/DiscordBot.Tests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "options": { + "env": { + "DOTNET_ROLL_FORWARD": "Major" + } + }, + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "detail": "Run the bot and restart it on code changes", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/DiscordBot/DiscordBot.csproj", + "--framework", + "net8.0" + ], + "options": { + "cwd": "${workspaceFolder}/DiscordBot", + "env": { + "DOTNET_ROLL_FORWARD": "Major" + } + }, + "isBackground": true, + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/DiscordBot/DiscordBot.csproj", + "--configuration", + "Release", + "--framework", + "net8.0", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "start database", + "detail": "Start the local PostgreSQL container from docker-compose.yml", + "command": "docker", + "type": "process", + "args": ["compose", "up", "-d", "db"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] + } + ] +} diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 8abb6470..8ed41567 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -2,6 +2,7 @@ Exe net8.0 + net8.0;net10.0 12 enable true @@ -21,4 +22,4 @@ - \ No newline at end of file + From aa00b4290bb70780d46d601c8c1c550ab020c5d0 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 17:43:19 +1000 Subject: [PATCH 07/14] feat(audit): log deleted messages as Components V2 cards with user identity - Add ILoggingService.LogComponentsToChannel (CV2, optional files, no pings) - Add AuditCard builder that keeps card text within Discord's 4000-char limit - Deleted-message cards show avatar, names, user ID, channel, attachments, replies and forwards - Log deletions by users who left; uncached deletes no longer download the channel - Log bulk deletes as one card grouped by author --- .../Services/AuditLogCardsTests.cs | 143 ++++++++ DiscordBot/Services/LoggingService.cs | 16 + DiscordBot/Services/Server/AuditLogCards.cs | 311 ++++++++++++++++++ DiscordBot/Services/Server/AuditLogService.cs | 85 ++++- 4 files changed, 537 insertions(+), 18 deletions(-) create mode 100644 DiscordBot.Tests/Services/AuditLogCardsTests.cs create mode 100644 DiscordBot/Services/Server/AuditLogCards.cs diff --git a/DiscordBot.Tests/Services/AuditLogCardsTests.cs b/DiscordBot.Tests/Services/AuditLogCardsTests.cs new file mode 100644 index 00000000..d579c63f --- /dev/null +++ b/DiscordBot.Tests/Services/AuditLogCardsTests.cs @@ -0,0 +1,143 @@ +using Discord; +using DiscordBot.Services.Server; + +namespace DiscordBot.Tests.Services; + +public class AuditLogCardsTests +{ + private static readonly CardUser User = new(123456789012345678, "Display (aka username)", "https://cdn.discordapp.com/embed/avatars/0.png"); + private static readonly CardChannel Channel = new(987654321098765432, "general"); + private static readonly DateTimeOffset Now = new(2026, 10, 25, 12, 0, 0, TimeSpan.Zero); + + /// All text Discord counts toward the 4000-character message limit. + internal static IEnumerable AllText(IEnumerable components) + { + foreach (var component in components) + { + switch (component) + { + case TextDisplayComponent text: + yield return text.Content; + break; + case ContainerComponent container: + foreach (var t in AllText(container.Components)) yield return t; + break; + case SectionComponent section: + foreach (var t in AllText(section.Components)) yield return t; + break; + } + } + } + + internal static string Text(MessageComponent message) => string.Join("\n", AllText(message.Components)); + + [Fact] + public void Fit_ShortText_Unchanged() + { + Assert.Equal("hello", AuditCard.Fit("hello", 10)); + } + + [Fact] + public void Fit_LongText_CutWithMarker() + { + var result = AuditCard.Fit(new string('a', 500), 100); + Assert.Equal(100, result.Length); + Assert.EndsWith(AuditCard.TruncatedMarker, result); + } + + [Fact] + public void Fit_MaxSmallerThanMarker_Empty() + { + Assert.Equal(string.Empty, AuditCard.Fit(new string('a', 500), 5)); + } + + [Fact] + public void Fit_DoesNotSplitSurrogatePair() + { + var text = new string('a', 9) + "๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€"; + var result = AuditCard.Fit(text, 10, "โ€ฆ"); + Assert.False(char.IsHighSurrogate(result[^2])); + Assert.True(result.Length <= 10); + } + + [Fact] + public void FitLines_AllFit_JoinedUnchanged() + { + Assert.Equal("a\nb\nc", AuditCard.FitLines(["a", "b", "c"], 100)); + } + + [Fact] + public void FitLines_Overflow_KeepsWholeLinesAndNotesRest() + { + var lines = Enumerable.Range(0, 50).Select(i => $"line {i} " + new string('x', 40)).ToList(); + var result = AuditCard.FitLines(lines, 300); + Assert.True(result.Length <= 300); + Assert.StartsWith("line 0 ", result); + Assert.Contains("more lines not shown", result); + } + + [Fact] + public void FitLines_FirstLineTooLong_ShowsPartOfIt() + { + var result = AuditCard.FitLines([new string('x', 1000), "second"], 200); + Assert.True(result.Length <= 200); + Assert.StartsWith("xxx", result); + Assert.Contains("1 more line not shown", result); + } + + [Fact] + public void DeletedMessageCard_MaximalInput_StaysWithinLimitAndKeepsIdentity() + { + var message = new AuditLogCards.DeletedMessage( + User with { Name = new string('n', 300), Note = "no longer in the server" }, + Channel with { Name = new string('c', 100), ParentName = "forum", Kind = "forum post" }, + 111111111111111111, + Now.AddMinutes(-5), + string.Join('\n', Enumerable.Repeat(new string('z', 99), 60)), + Enumerable.Range(0, 10).Select(i => new CardAttachment(new string('f', 200) + i + ".png", 5_000_000)).ToList(), + ["sticker one", "sticker two", "sticker three"], + "https://discord.com/channels/1/2/3"); + + var card = AuditLogCards.DeletedMessageCard(message, Now); + var text = Text(card); + + Assert.True(text.Length <= AuditCard.DiscordTextLimit, $"Card text was {text.Length} chars"); + Assert.Contains("User ID: `123456789012345678`", text); + Assert.Contains("<@123456789012345678>", text); + Assert.Contains("(truncated)", text); + Assert.Contains("Message ID: 111111111111111111", text); + } + + [Fact] + public void DeletedMessageCard_ForwardWithoutText_ShowsForwardedContent() + { + var message = new AuditLogCards.DeletedMessage(User, Channel, 1, Now, "", [], [], ForwardedContent: "free nitro here"); + var text = Text(AuditLogCards.DeletedMessageCard(message, Now)); + Assert.Contains("Forwarded message", text); + Assert.Contains("> free nitro here", text); + } + + [Fact] + public void UncachedDeletedMessageCard_Builds() + { + var text = Text(AuditLogCards.UncachedDeletedMessageCard(1234567890123, new CardChannel(5, null), Now)); + Assert.Contains("Message ID: 1234567890123", text); + Assert.Contains("<#5>", text); + } + + [Fact] + public void BulkDeletedCard_ManyLongMessages_StaysWithinLimit() + { + var authors = Enumerable.Range(1, 5).Select(i => User with { Id = (ulong)i, Name = $"user{i}" }).ToList(); + var messages = Enumerable.Range(0, 100) + .Select(i => new AuditLogCards.BulkDeletedMessage(authors[i % 5], new string('m', 2000), i % 3)) + .ToList(); + + var text = Text(AuditLogCards.BulkDeletedCard(Channel, messages, 7, Now)); + + Assert.True(text.Length <= AuditCard.DiscordTextLimit, $"Card text was {text.Length} chars"); + Assert.Contains("107 messages deleted at once (7 not cached", text); + Assert.Contains("more lines not shown", text); + Assert.Contains("**user1** <@1> `1` ยท 20 messages", text); + } +} diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index 447b06e2..6096076f 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -129,6 +129,16 @@ public async Task LogToChannel(string message, ExtendedLogSeverity severity = Ex await _logChannel.SendMessageAsync(message, false, embed); } + public async Task LogComponentsToChannel(MessageComponent components, IReadOnlyCollection? files = null) + { + if (_logChannel == null) + return; + if (files is { Count: > 0 }) + await _logChannel.SendFilesAsync(files, components: components, flags: MessageFlags.ComponentsV2, allowedMentions: AllowedMentions.None); + else + await _logChannel.SendMessageAsync(components: components, flags: MessageFlags.ComponentsV2, allowedMentions: AllowedMentions.None); + } + public async Task LogToFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) { PrepareLogFile(_logFilePath); @@ -269,6 +279,12 @@ public interface ILoggingService /// Embed, only used by Channel Logging Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null); + /// + /// Sends a Components V2 message to the log channel. Mentions never ping. + /// Files are uploaded with the message so components can show them via attachment://name. + /// + Task LogComponentsToChannel(MessageComponent components, IReadOnlyCollection? files = null); + /// /// 'Short hand' for logging to all CURRENT supported behaviours, console, channel and file. /// Same as calling `Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed);` diff --git a/DiscordBot/Services/Server/AuditLogCards.cs b/DiscordBot/Services/Server/AuditLogCards.cs new file mode 100644 index 00000000..fe98fc9e --- /dev/null +++ b/DiscordBot/Services/Server/AuditLogCards.cs @@ -0,0 +1,311 @@ +using Discord.WebSocket; + +namespace DiscordBot.Services.Server; + +/// Plain identity shown in a card header. +public sealed record CardUser(ulong Id, string Name, string AvatarUrl, string? Note = null) +{ + public static CardUser From(IUser user, string? note = null) => + new(user.Id, user.GetPreferredAndUsername(), user.GetDisplayAvatarUrl(size: 256), note); +} + +/// Plain channel info shown in a card header. is set for threads. +public sealed record CardChannel(ulong Id, string? Name, string? ParentName = null, string? Kind = null) +{ + public static CardChannel From(IChannel channel) + { + if (channel is SocketThreadChannel thread) + return new CardChannel(thread.Id, thread.Name, thread.ParentChannel?.Name, + thread.ParentChannel is SocketForumChannel ? "forum post" : "thread"); + return new CardChannel(channel.Id, channel.Name); + } +} + +public sealed record CardAttachment(string Filename, int Size); + +/// +/// Builds one Components V2 container and keeps the text of the whole message under Discord's +/// 4000 character limit. Header and footer are always kept; body parts are cut to fit, in order. +/// +public sealed class AuditCard +{ + public const int DiscordTextLimit = 4000; + // Headroom, since Discord may count some characters differently from .NET string length. + public const int DefaultBudget = 3900; + public const string TruncatedMarker = "\n-# โ€ฆ(truncated)"; + private const int MaxHeaderLength = 1000; + private const int MaxFooterLength = 500; + private const int MaxGalleryItems = 10; + + private abstract record Part; + private sealed record TextPart(string Text, int MaxLength) : Part; + private sealed record LinesPart(IReadOnlyList Lines, int MaxLength) : Part; + private sealed record SeparatorPart : Part; + private sealed record MediaPart(IReadOnlyList Urls) : Part; + + private readonly Color _accent; + private string? _header; + private CardUser? _user; + private string? _footer; + private readonly List _parts = []; + + public AuditCard(Color accent) => _accent = accent; + + /// Title, identity and channel lines. With a user, the avatar is shown as a thumbnail. + public AuditCard WithHeader(string title, CardUser? user, CardChannel? channel, params string[] extraLines) + { + _user = user; + var lines = new List { $"### {title}" }; + if (user != null) + { + lines.Add($"<@{user.Id}> {Format.Sanitize(user.Name)}"); + lines.Add(user.Note == null ? $"User ID: `{user.Id}`" : $"User ID: `{user.Id}` ยท {user.Note}"); + } + if (channel != null) + lines.Add($"In {ChannelText(channel)}"); + lines.AddRange(extraLines.Where(l => !string.IsNullOrWhiteSpace(l))); + _header = string.Join('\n', lines); + return this; + } + + public AuditCard AddText(string text, int maxLength = int.MaxValue) + { + if (!string.IsNullOrWhiteSpace(text)) + _parts.Add(new TextPart(text, maxLength)); + return this; + } + + /// Adds whole lines while there is room, then a note saying how many lines were left out. + public AuditCard AddLines(IEnumerable lines, int maxLength = int.MaxValue) + { + var list = lines.Where(l => !string.IsNullOrEmpty(l)).ToList(); + if (list.Count > 0) + _parts.Add(new LinesPart(list, maxLength)); + return this; + } + + public AuditCard AddSeparator() + { + _parts.Add(new SeparatorPart()); + return this; + } + + /// Image gallery (max 10). URLs can be attachment://name for files sent with the message. + public AuditCard AddMedia(IEnumerable urls) + { + var list = urls.Take(MaxGalleryItems).ToList(); + if (list.Count > 0) + _parts.Add(new MediaPart(list)); + return this; + } + + public AuditCard WithFooter(string text) + { + _footer = string.IsNullOrWhiteSpace(text) ? null : $"-# {text}"; + return this; + } + + public MessageComponent Build(int budget = DefaultBudget) + { + var header = Fit(_header ?? "### Audit log", MaxHeaderLength); + var footer = _footer == null ? null : Fit(_footer, MaxFooterLength); + var remaining = budget - header.Length - (footer?.Length ?? 0); + + var container = new ContainerBuilder().WithAccentColor(_accent); + if (_user != null) + container.WithSection(new SectionBuilder() + .AddComponent(new TextDisplayBuilder(header)) + .WithAccessory(new ThumbnailBuilder(new UnfurledMediaItemProperties(_user.AvatarUrl)))); + else + container.WithTextDisplay(header); + + foreach (var part in _parts) + { + switch (part) + { + case TextPart text: + { + var fitted = Fit(text.Text, Math.Min(text.MaxLength, remaining)); + if (fitted.Length == 0) continue; + remaining -= fitted.Length; + container.WithTextDisplay(fitted); + break; + } + case LinesPart lines: + { + var fitted = FitLines(lines.Lines, Math.Min(lines.MaxLength, remaining)); + if (fitted.Length == 0) continue; + remaining -= fitted.Length; + container.WithTextDisplay(fitted); + break; + } + case SeparatorPart: + container.WithSeparator(); + break; + case MediaPart media: + container.WithMediaGallery(media.Urls); + break; + } + } + + if (footer != null) + container.WithTextDisplay(footer); + + return new ComponentBuilderV2().WithContainer(container).Build(); + } + + /// Cuts to at most chars, ending with when cut. + public static string Fit(string text, int max, string marker = TruncatedMarker) + { + if (text.Length <= max) + return text; + if (max <= marker.Length) + return string.Empty; + var cut = max - marker.Length; + if (char.IsHighSurrogate(text[cut - 1])) + cut--; + return text[..cut] + marker; + } + + /// Joins whole lines up to chars, adding a note for lines left out. + public static string FitLines(IReadOnlyList lines, int max) + { + var all = string.Join('\n', lines); + if (all.Length <= max) + return all; + + var builder = new System.Text.StringBuilder(); + for (var i = 0; i < lines.Count; i++) + { + var separator = builder.Length == 0 ? 0 : 1; + // Keep room for the note about the lines after this one. + if (builder.Length + separator + lines[i].Length + OmittedNote(lines.Count - i - 1).Length <= max) + { + if (separator == 1) builder.Append('\n'); + builder.Append(lines[i]); + continue; + } + + if (builder.Length == 0) + { + // Not even the first line fits: show part of it. + var note = OmittedNote(lines.Count - 1); + return Fit(lines[0], max - note.Length, "โ€ฆ") + note; + } + var finalNote = OmittedNote(lines.Count - i); + return builder.Length + finalNote.Length <= max ? builder.Append(finalNote).ToString() : builder.ToString(); + } + return builder.ToString(); + } + + private static string OmittedNote(int count) => count == 0 ? string.Empty : $"\n-# โ€ฆ{count} more line{(count == 1 ? "" : "s")} not shown"; + + public static string ChannelText(CardChannel channel) + { + var text = $"<#{channel.Id}>"; + if (channel.Name != null) + text += $" (`#{channel.Name.Replace("`", "'")}`" + + (channel.ParentName != null ? $", {channel.Kind ?? "thread"} in `#{channel.ParentName.Replace("`", "'")}`" : "") + ")"; + return text; + } +} + +/// Card layouts for the audit log. Inputs are plain values so the layouts can be tested. +public static class AuditLogCards +{ + public static readonly Color DeletedColor = new(200, 128, 128); + public static readonly Color EditedColor = new(255, 255, 128); + public static readonly Color BulkDeletedColor = new(160, 96, 96); + + public const int MaxContentLength = 2000; + private const int MaxBulkLineLength = 200; + + public static string Timestamp(DateTimeOffset time, char style = 'f') => $""; + + /// Block-quotes every line. Empty text becomes an italic placeholder. + public static string Quote(string text) => + string.IsNullOrWhiteSpace(text) ? "*(no text)*" : string.Join('\n', text.Split('\n').Select(l => $"> {l}")); + + public static string FormatSize(long bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:0.#} KB", + _ => $"{bytes / (1024.0 * 1024.0):0.#} MB", + }; + + public static string AttachmentLines(string title, IReadOnlyList attachments) => + $"**{title} ({attachments.Count})**\n" + + string.Join('\n', attachments.Select(a => $"- `{a.Filename.Replace("`", "'")}` ({FormatSize(a.Size)})")); + + public static string JumpUrl(ulong guildId, ulong channelId, ulong messageId) => + $"https://discord.com/channels/{guildId}/{channelId}/{messageId}"; + + public sealed record DeletedMessage( + CardUser Author, + CardChannel Channel, + ulong MessageId, + DateTimeOffset SentAt, + string Content, + IReadOnlyList Attachments, + IReadOnlyList StickerNames, + string? ReplyToUrl = null, + string? ForwardedContent = null); + + public static MessageComponent DeletedMessageCard(DeletedMessage message, DateTimeOffset deletedAt) + { + var card = new AuditCard(DeletedColor) + .WithHeader("๐Ÿ—‘๏ธ Message deleted", message.Author, message.Channel, + message.ReplyToUrl != null ? $"Reply to {message.ReplyToUrl}" : ""); + + if (message.ForwardedContent != null && string.IsNullOrWhiteSpace(message.Content)) + card.AddText("**Forwarded message**\n" + Quote(message.ForwardedContent), MaxContentLength); + else + card.AddText(Quote(message.Content), MaxContentLength); + + if (message.Attachments.Count > 0) + card.AddText(AttachmentLines("Attachments", message.Attachments)); + if (message.StickerNames.Count > 0) + card.AddText("**Stickers:** " + string.Join(", ", message.StickerNames.Select(Format.Sanitize))); + + return card + .WithFooter($"Message ID: {message.MessageId} ยท Sent {Timestamp(message.SentAt)} ยท Deleted {Timestamp(deletedAt)}") + .Build(); + } + + public static MessageComponent UncachedDeletedMessageCard(ulong messageId, CardChannel channel, DateTimeOffset deletedAt) => + new AuditCard(DeletedColor) + .WithHeader("๐Ÿ—‘๏ธ Uncached message deleted", null, channel, "-# The bot did not have this message cached, so its author and content are unknown.") + .WithFooter($"Message ID: {messageId} ยท Sent {Timestamp(SnowflakeUtils.FromSnowflake(messageId))} ยท Deleted {Timestamp(deletedAt)}") + .Build(); + + public sealed record BulkDeletedMessage(CardUser Author, string Content, int AttachmentCount); + + public static MessageComponent BulkDeletedCard(CardChannel channel, IReadOnlyList cached, int uncachedCount, DateTimeOffset deletedAt) + { + var total = cached.Count + uncachedCount; + var summary = $"{total} message{(total == 1 ? "" : "s")} deleted at once" + + (uncachedCount > 0 ? $" ({uncachedCount} not cached, content unknown)" : ""); + + var lines = new List(); + foreach (var group in cached.GroupBy(m => m.Author.Id)) + { + var author = group.First().Author; + lines.Add($"**{Format.Sanitize(author.Name)}** <@{author.Id}> `{author.Id}` ยท {group.Count()} message{(group.Count() == 1 ? "" : "s")}"); + foreach (var msg in group) + { + var text = string.Join(' ', msg.Content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + if (msg.AttachmentCount > 0) + text += $" *(+{msg.AttachmentCount} attachment{(msg.AttachmentCount == 1 ? "" : "s")})*"; + if (string.IsNullOrWhiteSpace(text)) + text = "*(no text)*"; + lines.Add("> " + AuditCard.Fit(text, MaxBulkLineLength, "โ€ฆ")); + } + } + + return new AuditCard(BulkDeletedColor) + .WithHeader("๐Ÿงน Messages bulk deleted", null, channel, summary) + .AddLines(lines) + .WithFooter($"Deleted {Timestamp(deletedAt)} ยท Usually a moderator purge or a ban that removed message history") + .Build(); + } +} diff --git a/DiscordBot/Services/Server/AuditLogService.cs b/DiscordBot/Services/Server/AuditLogService.cs index a2c7dc2e..d0e20f37 100644 --- a/DiscordBot/Services/Server/AuditLogService.cs +++ b/DiscordBot/Services/Server/AuditLogService.cs @@ -7,9 +7,9 @@ namespace DiscordBot.Services.Server; public class AuditLogService { private readonly ILoggingService _loggingService; + private readonly DiscordSocketClient _client; private const int MaxMessageLength = 800; - private static readonly Color DeletedMessageColor = new(200, 128, 128); private static readonly Color EditedMessageColor = new(255, 255, 128); private readonly IMessageChannel? _botAnnouncementChannel = null!; @@ -17,8 +17,10 @@ public class AuditLogService public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService) { _loggingService = loggingService; + _client = client; client.MessageDeleted += EventGuard.Guarded, Cacheable>(MessageDeleted, nameof(MessageDeleted)); + client.MessagesBulkDeleted += EventGuard.Guarded>, Cacheable>(MessagesBulkDeleted, nameof(MessagesBulkDeleted)); client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(MessageUpdated, nameof(MessageUpdated)); client.UserLeft += EventGuard.Guarded(UserLeft, nameof(UserLeft)); client.GuildMemberUpdated += EventGuard.Guarded, SocketGuildUser>(GuildMemberUpdated, nameof(GuildMemberUpdated)); @@ -29,34 +31,81 @@ public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggin private async Task MessageDeleted(Cacheable message, Cacheable channel) { + if (channel.Id == _botAnnouncementChannel?.Id) + return; + if (message.HasValue == false) { - await _loggingService.LogChannelAndFile($"An uncached Message snowflake:`{message.Id}` was deleted from channel <#{(await channel.GetOrDownloadAsync()).Id}>"); + // Don't download the channel: it may be a thread that was just deleted. + IChannel? cachedChannel = channel.HasValue ? channel.Value : _client.GetChannel(channel.Id); + if (cachedChannel is IPrivateChannel) + return; + var cardChannel = cachedChannel != null ? CardChannel.From(cachedChannel) : new CardChannel(channel.Id, null); + await _loggingService.LogComponentsToChannel( + AuditLogCards.UncachedDeletedMessageCard(message.Id, cardChannel, DateTimeOffset.UtcNow)); return; } - if (message.Value.Author.IsBot || channel.Id == _botAnnouncementChannel?.Id) + var deleted = message.Value; + if (deleted.Author.IsBot || deleted.Channel is not IGuildChannel guildChannel) return; - var guildUser = message.Value.Author as SocketGuildUser; - if (guildUser == null) + var card = AuditLogCards.DeletedMessageCard(ToDeletedMessage(deleted, guildChannel.Guild), DateTimeOffset.UtcNow); + await _loggingService.LogComponentsToChannel(card); + } + + private async Task MessagesBulkDeleted(IReadOnlyCollection> messages, Cacheable channel) + { + if (channel.Id == _botAnnouncementChannel?.Id) return; - var content = message.Value.Content; - if (content.Length > MaxMessageLength) - content = content[..MaxMessageLength]; + IChannel? cachedChannel = channel.HasValue ? channel.Value : _client.GetChannel(channel.Id); + if (cachedChannel is IPrivateChannel) + return; + var guild = (cachedChannel as IGuildChannel)?.Guild; + + var cached = messages.Where(m => m.HasValue).Select(m => m.Value).OrderBy(m => m.Timestamp).ToList(); + var uncachedCount = messages.Count - cached.Count; + var listed = cached + .Where(m => !m.Author.IsBot) + .Select(m => new AuditLogCards.BulkDeletedMessage(AuthorCard(m.Author, guild), m.Content, m.Attachments.Count)) + .ToList(); + // Bot messages count towards the total but aren't listed. + uncachedCount += cached.Count - listed.Count; + if (listed.Count == 0 && uncachedCount == 0) + return; - var user = message.Value.Author; - var builder = new EmbedBuilder() - .WithColor(DeletedMessageColor) - .WithTimestamp(message.Value.Timestamp) - .FooterInChannel(message.Value.Channel) - .AddAuthorWithAction(user, "Deleted a message", true) - .AddField($"Deleted Message {(content.Length != message.Value.Content.Length ? "(truncated)" : "")}", - content); - var embed = builder.Build(); + var cardChannel = cachedChannel != null ? CardChannel.From(cachedChannel) : new CardChannel(channel.Id, null); + await _loggingService.LogComponentsToChannel( + AuditLogCards.BulkDeletedCard(cardChannel, listed, uncachedCount, DateTimeOffset.UtcNow)); + } - await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); + private static CardUser AuthorCard(IUser author, IGuild? guild) + { + // Deletions from people who left or were banned are what scam waves look like, so call it out. + var inServer = guild is SocketGuild socketGuild ? socketGuild.GetUser(author.Id) != null : true; + return CardUser.From(author, inServer ? null : "no longer in the server"); + } + + private static AuditLogCards.DeletedMessage ToDeletedMessage(IMessage message, IGuild guild) + { + var forwarded = (message as IUserMessage)?.ForwardedMessages?.Select(f => f.Message).FirstOrDefault(m => m != null); + var attachments = message.Attachments.Count > 0 || forwarded == null ? message.Attachments : forwarded.Attachments; + + string? replyTo = null; + if (message.Type == MessageType.Reply && message.Reference?.MessageId.IsSpecified == true) + replyTo = AuditLogCards.JumpUrl(guild.Id, message.Reference.ChannelId, message.Reference.MessageId.Value); + + return new AuditLogCards.DeletedMessage( + AuthorCard(message.Author, guild), + CardChannel.From(message.Channel), + message.Id, + message.Timestamp, + message.Content, + attachments.Select(a => new CardAttachment(a.Filename, a.Size)).ToList(), + message.Stickers.Select(st => st.Name).ToList(), + replyTo, + forwarded?.Content); } private async Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) From 3048274e28b56032b0145b188a74076efc6ba16d Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 17:45:56 +1000 Subject: [PATCH 08/14] fix(audit): only log real edits; log thread and forum post deletions - Classify message updates so unfurls, pins and flag changes no longer log as edits - Edit cards show before/after text and attachment changes; uncached edits show the new text - Log thread and forum post deletions with owner, tags, starter post and who deleted it - Document the audit log cards and the optional View Audit Log permission --- .../Services/AuditLogCardsTests.cs | 53 +++++++ .../Services/MessageEditClassifierTests.cs | 99 ++++++++++++ DiscordBot/Services/Server/AuditLogCards.cs | 91 ++++++++++- DiscordBot/Services/Server/AuditLogService.cs | 150 +++++++++++++----- .../Services/Server/MessageEditClassifier.cs | 65 ++++++++ docs/features.md | 2 +- 6 files changed, 418 insertions(+), 42 deletions(-) create mode 100644 DiscordBot.Tests/Services/MessageEditClassifierTests.cs create mode 100644 DiscordBot/Services/Server/MessageEditClassifier.cs diff --git a/DiscordBot.Tests/Services/AuditLogCardsTests.cs b/DiscordBot.Tests/Services/AuditLogCardsTests.cs index d579c63f..8e8ec63d 100644 --- a/DiscordBot.Tests/Services/AuditLogCardsTests.cs +++ b/DiscordBot.Tests/Services/AuditLogCardsTests.cs @@ -140,4 +140,57 @@ public void BulkDeletedCard_ManyLongMessages_StaysWithinLimit() Assert.Contains("more lines not shown", text); Assert.Contains("**user1** <@1> `1` ยท 20 messages", text); } + + [Fact] + public void EditedMessageCard_LongVersions_StaysWithinLimitAndShowsBoth() + { + var edit = new AuditLogCards.EditedMessage(User, Channel, 42, "https://discord.com/channels/1/2/42", Now.AddHours(-1), Now, + new string('b', 4000), new string('a', 4000), + Enumerable.Range(0, 10).Select(i => new CardAttachment(new string('r', 150) + i, 1024, (ulong)i)).ToList(), []); + + var text = Text(AuditLogCards.EditedMessageCard(edit)); + + Assert.True(text.Length <= AuditCard.DiscordTextLimit, $"Card text was {text.Length} chars"); + Assert.Contains("**Before**", text); + Assert.Contains("**After**", text); + Assert.Contains("[Jump to message](https://discord.com/channels/1/2/42)", text); + } + + [Fact] + public void EditedMessageCard_Uncached_ShowsAfterOnly() + { + var edit = new AuditLogCards.EditedMessage(User, Channel, 42, "https://x", Now, Now, null, "now this", [], []); + var text = Text(AuditLogCards.EditedMessageCard(edit)); + Assert.Contains("previous version not cached", text); + Assert.DoesNotContain("**Before**", text); + Assert.Contains("> now this", text); + } + + [Fact] + public void DeletedThreadCard_Cached_ShowsOwnerTagsAndStarter() + { + var thread = new AuditLogCards.DeletedThread( + 1234567890123456789, "Help with shaders", true, new CardChannel(5, "unity-help"), + User, User.Id, ["Shaders", "URP"], 12, "My shader is pink", [new CardAttachment("pink.png", 2048)], + User with { Id = 99, Name = "Mod" }, true); + + var text = Text(AuditLogCards.DeletedThreadCard(thread, Now)); + + Assert.Contains("Forum post deleted", text); + Assert.Contains("Owner: <@123456789012345678>", text); + Assert.Contains("`Shaders`, `URP`", text); + Assert.Contains("Deleted by: <@99>", text); + Assert.Contains("> My shader is pink", text); + Assert.Contains("12 messages", text); + } + + [Fact] + public void DeletedThreadCard_UncachedNoAudit_Builds() + { + var thread = new AuditLogCards.DeletedThread(77, null, false, null, null, null, [], null, null, [], null, false); + var text = Text(AuditLogCards.DeletedThreadCard(thread, Now)); + Assert.Contains("Thread deleted", text); + Assert.Contains("Thread ID: 77", text); + Assert.Contains("not have this thread cached", text); + } } diff --git a/DiscordBot.Tests/Services/MessageEditClassifierTests.cs b/DiscordBot.Tests/Services/MessageEditClassifierTests.cs new file mode 100644 index 00000000..0b50efc5 --- /dev/null +++ b/DiscordBot.Tests/Services/MessageEditClassifierTests.cs @@ -0,0 +1,99 @@ +using DiscordBot.Services.Server; + +namespace DiscordBot.Tests.Services; + +public class MessageEditClassifierTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 25, 12, 0, 0, TimeSpan.Zero); + private static readonly CardAttachment Image = new("shot.png", 1000, 11); + private static readonly CardAttachment Zip = new("project.zip", 5000, 12); + + private static MessageVersion Version(string content, DateTimeOffset? editedAt, params CardAttachment[] attachments) => + new(content, attachments, editedAt); + + [Fact] + public void EmbedUnfurl_NoEditedTimestamp_Ignored() + { + var before = Version("look https://unity.com", null); + var after = Version("look https://unity.com", null); + Assert.Equal(EditKind.Ignore, MessageEditClassifier.Classify(before, after, Now).Kind); + } + + [Fact] + public void AttachmentMessageUnfurl_UncachedBefore_NoEditedTimestamp_Ignored() + { + // The old code logged this case as an empty edit. + var after = Version("my build https://example.com", null, Image); + Assert.Equal(EditKind.Ignore, MessageEditClassifier.Classify(null, after, Now).Kind); + } + + [Fact] + public void PinOfOldEditedMessage_Unchanged_Ignored() + { + var edited = Now.AddDays(-3); + var before = Version("text", edited, Image); + var after = Version("text", edited, Image); + Assert.Equal(EditKind.Ignore, MessageEditClassifier.Classify(before, after, Now).Kind); + } + + [Fact] + public void PinOfOldEditedMessage_Uncached_Ignored() + { + var after = Version("text", Now.AddDays(-3), Image); + Assert.Equal(EditKind.Ignore, MessageEditClassifier.Classify(null, after, Now).Kind); + } + + [Fact] + public void UnfurlAfterEdit_SameContent_Ignored() + { + var edited = Now.AddSeconds(-1); + var before = Version("edited https://unity.com", edited); + var after = Version("edited https://unity.com", edited); + Assert.Equal(EditKind.Ignore, MessageEditClassifier.Classify(before, after, Now).Kind); + } + + [Fact] + public void RealTextEdit_Edited() + { + var before = Version("helo", null); + var after = Version("hello", Now); + var decision = MessageEditClassifier.Classify(before, after, Now); + Assert.Equal(EditKind.Edited, decision.Kind); + Assert.True(decision.ContentChanged); + Assert.Empty(decision.Removed); + } + + [Fact] + public void AttachmentRemoved_ReportsRemovedOnly() + { + var before = Version("files", null, Image, Zip); + var after = Version("files", Now, Image); + var decision = MessageEditClassifier.Classify(before, after, Now); + Assert.Equal(EditKind.Edited, decision.Kind); + Assert.False(decision.ContentChanged); + Assert.Equal([Zip], decision.Removed); + Assert.Empty(decision.Added); + } + + [Fact] + public void UncachedRecentEdit_EditedUncached() + { + var after = Version("new text", Now.AddSeconds(-5)); + Assert.Equal(EditKind.EditedUncached, MessageEditClassifier.Classify(null, after, Now).Kind); + } + + [Fact] + public void UncachedEdit_AlreadyLogged_Ignored() + { + var edited = Now.AddSeconds(-5); + var after = Version("new text", edited); + Assert.Equal(EditKind.Ignore, MessageEditClassifier.Classify(null, after, Now, lastLoggedEditedAt: edited).Kind); + } + + [Fact] + public void UncachedEdit_NewerEditThanLogged_EditedUncached() + { + var after = Version("newer text", Now.AddSeconds(-1)); + Assert.Equal(EditKind.EditedUncached, MessageEditClassifier.Classify(null, after, Now, lastLoggedEditedAt: Now.AddSeconds(-30)).Kind); + } +} diff --git a/DiscordBot/Services/Server/AuditLogCards.cs b/DiscordBot/Services/Server/AuditLogCards.cs index fe98fc9e..d8f6d215 100644 --- a/DiscordBot/Services/Server/AuditLogCards.cs +++ b/DiscordBot/Services/Server/AuditLogCards.cs @@ -2,8 +2,8 @@ namespace DiscordBot.Services.Server; -/// Plain identity shown in a card header. -public sealed record CardUser(ulong Id, string Name, string AvatarUrl, string? Note = null) +/// Plain identity shown in a card header. prefixes the name line, e.g. "Owner". +public sealed record CardUser(ulong Id, string Name, string AvatarUrl, string? Note = null, string? Label = null) { public static CardUser From(IUser user, string? note = null) => new(user.Id, user.GetPreferredAndUsername(), user.GetDisplayAvatarUrl(size: 256), note); @@ -21,7 +21,7 @@ public static CardChannel From(IChannel channel) } } -public sealed record CardAttachment(string Filename, int Size); +public sealed record CardAttachment(string Filename, int Size, ulong Id = 0); /// /// Builds one Components V2 container and keeps the text of the whole message under Discord's @@ -58,7 +58,7 @@ public AuditCard WithHeader(string title, CardUser? user, CardChannel? channel, var lines = new List { $"### {title}" }; if (user != null) { - lines.Add($"<@{user.Id}> {Format.Sanitize(user.Name)}"); + lines.Add($"{(user.Label != null ? $"{user.Label}: " : "")}<@{user.Id}> {Format.Sanitize(user.Name)}"); lines.Add(user.Note == null ? $"User ID: `{user.Id}`" : $"User ID: `{user.Id}` ยท {user.Note}"); } if (channel != null) @@ -308,4 +308,87 @@ public static MessageComponent BulkDeletedCard(CardChannel channel, IReadOnlyLis .WithFooter($"Deleted {Timestamp(deletedAt)} ยท Usually a moderator purge or a ban that removed message history") .Build(); } + + public sealed record EditedMessage( + CardUser Author, + CardChannel Channel, + ulong MessageId, + string JumpUrl, + DateTimeOffset SentAt, + DateTimeOffset EditedAt, + string? Before, + string After, + IReadOnlyList Removed, + IReadOnlyList Added); + + private const int MaxEditVersionLength = 1600; + + /// Edit card. A null means the previous version wasn't cached. + public static MessageComponent EditedMessageCard(EditedMessage edit) + { + var card = new AuditCard(EditedColor) + .WithHeader(edit.Before == null ? "โœ๏ธ Message edited (previous version not cached)" : "โœ๏ธ Message edited", + edit.Author, edit.Channel, $"[Jump to message]({edit.JumpUrl})"); + + if (edit.Before == null) + card.AddText("**After**\n" + Quote(edit.After), MaxEditVersionLength); + else if (edit.Before == edit.After) + card.AddText("-# Text unchanged"); + else + card.AddText("**Before**\n" + Quote(edit.Before), MaxEditVersionLength) + .AddText("**After**\n" + Quote(edit.After), MaxEditVersionLength); + + if (edit.Removed.Count > 0) + card.AddText(AttachmentLines("Attachments removed", edit.Removed)); + if (edit.Added.Count > 0) + card.AddText(AttachmentLines("Attachments added", edit.Added)); + + return card + .WithFooter($"Message ID: {edit.MessageId} ยท Sent {Timestamp(edit.SentAt)} ยท Edited {Timestamp(edit.EditedAt)}") + .Build(); + } + + public sealed record DeletedThread( + ulong ThreadId, + string? Name, + bool IsForumPost, + CardChannel? Parent, + CardUser? Owner, + ulong? OwnerId, + IReadOnlyList Tags, + int? MessageCount, + string? StarterContent, + IReadOnlyList StarterAttachments, + CardUser? DeletedBy, + bool WasCached); + + private const int MaxStarterLength = 1500; + + public static MessageComponent DeletedThreadCard(DeletedThread thread, DateTimeOffset deletedAt) + { + var kind = thread.IsForumPost ? "Forum post" : "Thread"; + var extra = new List + { + thread.Name != null ? $"Name: **{Format.Sanitize(thread.Name)}**" : "", + thread.Owner == null && thread.OwnerId != null ? $"Owner: <@{thread.OwnerId}> `{thread.OwnerId}`" : "", + thread.Tags.Count > 0 ? "Tags: " + string.Join(", ", thread.Tags.Select(t => $"`{t.Replace("`", "'")}`")) : "", + thread.DeletedBy != null ? $"Deleted by: <@{thread.DeletedBy.Id}> {Format.Sanitize(thread.DeletedBy.Name)} `{thread.DeletedBy.Id}`" : "", + thread.WasCached ? "" : "-# The bot did not have this thread cached, so owner and messages are unknown.", + }; + + var card = new AuditCard(DeletedColor) + .WithHeader($"๐Ÿงต {kind} deleted", thread.Owner == null ? null : thread.Owner with { Label = "Owner" }, thread.Parent, extra.ToArray()); + + if (thread.StarterContent != null) + card.AddText((thread.IsForumPost ? "**Post**\n" : "**First message**\n") + Quote(thread.StarterContent), MaxStarterLength); + if (thread.StarterAttachments.Count > 0) + card.AddText(AttachmentLines("Attachments", thread.StarterAttachments)); + + var footer = new List { $"Thread ID: {thread.ThreadId}", $"Created {Timestamp(SnowflakeUtils.FromSnowflake(thread.ThreadId))}" }; + if (thread.MessageCount != null) + footer.Add($"{thread.MessageCount} message{(thread.MessageCount == 1 ? "" : "s")}"); + footer.Add($"Deleted {Timestamp(deletedAt)}"); + + return card.WithFooter(string.Join(" ยท ", footer)).Build(); + } } diff --git a/DiscordBot/Services/Server/AuditLogService.cs b/DiscordBot/Services/Server/AuditLogService.cs index d0e20f37..23358db6 100644 --- a/DiscordBot/Services/Server/AuditLogService.cs +++ b/DiscordBot/Services/Server/AuditLogService.cs @@ -1,4 +1,7 @@ +using System.Collections.Concurrent; using System.Globalization; +using Discord.Net; +using Discord.Rest; using Discord.WebSocket; using DiscordBot.Settings; @@ -8,9 +11,11 @@ public class AuditLogService { private readonly ILoggingService _loggingService; private readonly DiscordSocketClient _client; + private readonly BotSettings _settings; - private const int MaxMessageLength = 800; - private static readonly Color EditedMessageColor = new(255, 255, 128); + // Uncached edits already logged (message ID -> edited time), so the unfurl update after an edit isn't logged twice. + private readonly ConcurrentDictionary _uncachedEditsLogged = new(); + private int _auditLogWarned; private readonly IMessageChannel? _botAnnouncementChannel = null!; @@ -18,10 +23,12 @@ public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggin { _loggingService = loggingService; _client = client; + _settings = settings; client.MessageDeleted += EventGuard.Guarded, Cacheable>(MessageDeleted, nameof(MessageDeleted)); client.MessagesBulkDeleted += EventGuard.Guarded>, Cacheable>(MessagesBulkDeleted, nameof(MessagesBulkDeleted)); client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(MessageUpdated, nameof(MessageUpdated)); + client.ThreadDeleted += EventGuard.Guarded>(ThreadDeleted, nameof(ThreadDeleted)); client.UserLeft += EventGuard.Guarded(UserLeft, nameof(UserLeft)); client.GuildMemberUpdated += EventGuard.Guarded, SocketGuildUser>(GuildMemberUpdated, nameof(GuildMemberUpdated)); @@ -110,54 +117,123 @@ private static AuditLogCards.DeletedMessage ToDeletedMessage(IMessage message, I private async Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) { - if (after.Author.IsBot || channel.Id == _botAnnouncementChannel?.Id) + if (after.Author.IsBot || channel.Id == _botAnnouncementChannel?.Id || channel is not SocketGuildChannel guildChannel) return; - bool isCached = true; - string content = ""; - var beforeMessage = await before.GetOrDownloadAsync(); - if (beforeMessage == null || beforeMessage.Content == after.Content) - isCached = false; - else - content = beforeMessage.Content; + // Never download "before": that returns the edited message and made unfurls look like edits. + var beforeVersion = before.HasValue ? MessageVersion.From(before.Value) : null; + var afterVersion = MessageVersion.From(after); + var now = DateTimeOffset.UtcNow; + DateTimeOffset? lastLogged = _uncachedEditsLogged.TryGetValue(after.Id, out var logged) ? logged : null; + + var decision = MessageEditClassifier.Classify(beforeVersion, afterVersion, now, lastLogged); + if (decision.Kind == EditKind.Ignore) + return; + if (decision.Kind == EditKind.EditedUncached) + RememberUncachedEdit(after.Id, afterVersion.EditedAt!.Value, now); - if (content == after.Content) + var card = AuditLogCards.EditedMessageCard(new AuditLogCards.EditedMessage( + AuthorCard(after.Author, guildChannel.Guild), + CardChannel.From(channel), + after.Id, + after.GetJumpUrl(), + after.Timestamp, + afterVersion.EditedAt!.Value, + beforeVersion?.Content, + after.Content, + decision.Removed, + decision.Added)); + await _loggingService.LogComponentsToChannel(card); + } + + private void RememberUncachedEdit(ulong messageId, DateTimeOffset editedAt, DateTimeOffset now) + { + _uncachedEditsLogged[messageId] = editedAt; + if (_uncachedEditsLogged.Count <= 200) return; - if (content.Length == 0 && (beforeMessage?.Attachments.Count ?? 0) == 0) + foreach (var entry in _uncachedEditsLogged.Where(e => now - e.Value > MessageEditClassifier.UncachedEditMaxAge).ToList()) + _uncachedEditsLogged.TryRemove(entry.Key, out _); + } + + private Task ThreadDeleted(Cacheable thread) + { + // The audit-log lookup waits a moment, so don't hold up the gateway. + LogThreadDeletion(thread.Id, thread.HasValue ? thread.Value : null, DateTimeOffset.UtcNow) + .SafeFireAndForget(nameof(ThreadDeleted)); + return Task.CompletedTask; + } + + private async Task LogThreadDeletion(ulong threadId, SocketThreadChannel? thread, DateTimeOffset deletedAt) + { + var guild = thread?.Guild ?? _client.GetGuild(_settings.GuildId); + if (guild == null) return; - bool isTruncated = false; - if (content.Length > MaxMessageLength) + var (deletedBy, auditData) = await FindThreadDeleteAudit(guild, threadId); + + // Tag IDs are unique, so they also tell us the forum when the thread wasn't cached. + var tagIds = thread?.AppliedTags ?? auditData?.AppliedTags ?? []; + var parent = thread?.ParentChannel as SocketGuildChannel + ?? guild.ForumChannels.FirstOrDefault(f => f.Tags.Any(t => tagIds.Contains(t.Id))); + var forum = parent as SocketForumChannel; + var tags = forum?.Tags.Where(t => tagIds.Contains(t.Id)).Select(t => t.Name).ToList() ?? []; + + CardUser? owner = null; + ulong? ownerId = (thread as IThreadChannel)?.OwnerId; + if (ownerId != null) { - content = content[..MaxMessageLength]; - isTruncated = true; + IUser? ownerUser = guild.GetUser(ownerId.Value) ?? (IUser?)_client.GetUser(ownerId.Value); + if (ownerUser != null) + owner = AuthorCard(ownerUser, guild); } - var user = after.Author; - var builder = new EmbedBuilder() - .WithColor(EditedMessageColor) - .WithTimestamp(after.Timestamp) - .FooterInChannel(after.Channel) - .AddAuthorWithAction(user, "Updated a message", true); - if (isCached) + var starter = thread?.GetCachedMessage(threadId); + var info = new AuditLogCards.DeletedThread( + threadId, + thread?.Name ?? auditData?.ThreadName, + forum != null, + parent != null ? CardChannel.From(parent) : null, + owner, + ownerId, + tags, + thread?.MessageCount, + starter?.Content, + starter?.Attachments.Select(a => new CardAttachment(a.Filename, a.Size)).ToList() ?? [], + deletedBy != null ? CardUser.From(deletedBy) : null, + thread != null); + + await _loggingService.LogComponentsToChannel(AuditLogCards.DeletedThreadCard(info, deletedAt)); + } + + private async Task<(IUser? DeletedBy, ThreadDeleteAuditLogData? Data)> FindThreadDeleteAudit(SocketGuild guild, ulong threadId) + { + if (!guild.CurrentUser.GuildPermissions.ViewAuditLog) { - builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); - if (beforeMessage?.Attachments.Count > 0) - { - var attachments = beforeMessage.Attachments.Where(x => after.Attachments.All(y => y.Url != x.Url)); - var removedAttachments = attachments.ToList(); - if (removedAttachments.Any()) - { - var attachmentString = string.Join("\n", removedAttachments.Select(x => $"[{x.Filename}]({x.Url})")); - builder.AddField($"Previous attachments ({removedAttachments.Count()})", attachmentString); - } - } + WarnAuditLogOnce("missing the View Audit Log permission"); + return default; } - builder.WithDescription($"Message: [{after.Id}]({after.GetJumpUrl()})"); - var embed = builder.Build(); + // The audit-log entry can show up a little after the gateway event. + await Task.Delay(TimeSpan.FromSeconds(2)); + try + { + var entries = await guild.GetAuditLogsAsync(10, actionType: ActionType.ThreadDelete).FlattenAsync(); + var entry = entries.FirstOrDefault(e => + e.Data is ThreadDeleteAuditLogData data && data.ThreadId == threadId && + DateTimeOffset.UtcNow - e.CreatedAt < TimeSpan.FromSeconds(30)); + return (entry?.User, entry?.Data as ThreadDeleteAuditLogData); + } + catch (HttpException e) + { + WarnAuditLogOnce($"audit log request failed ({e.HttpCode})"); + return default; + } + } - await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); + private void WarnAuditLogOnce(string reason) + { + if (Interlocked.Exchange(ref _auditLogWarned, 1) == 0) + LoggingService.LogToConsole($"[{nameof(AuditLogService)}] Can't show who deleted threads: {reason}", LogSeverity.Warning); } private async Task UserLeft(SocketGuild guild, SocketUser user) diff --git a/DiscordBot/Services/Server/MessageEditClassifier.cs b/DiscordBot/Services/Server/MessageEditClassifier.cs new file mode 100644 index 00000000..588a7d1a --- /dev/null +++ b/DiscordBot/Services/Server/MessageEditClassifier.cs @@ -0,0 +1,65 @@ +namespace DiscordBot.Services.Server; + +/// Plain snapshot of a message version, for comparing before and after an update. +public sealed record MessageVersion(string Content, IReadOnlyList Attachments, DateTimeOffset? EditedAt) +{ + public static MessageVersion From(IMessage message) => + new(message.Content, + message.Attachments.Select(a => new CardAttachment(a.Filename, a.Size, a.Id)).ToList(), + message.EditedTimestamp); +} + +public enum EditKind +{ + /// Not an author edit (link unfurl, pin, thread created, embeds suppressed, ...). + Ignore, + /// Author edit with the previous version known. + Edited, + /// Recent author edit, but the previous version wasn't cached. + EditedUncached, +} + +public sealed record EditDecision(EditKind Kind, bool ContentChanged, IReadOnlyList Removed, IReadOnlyList Added) +{ + public static readonly EditDecision Ignore = new(EditKind.Ignore, false, [], []); +} + +/// +/// Decides whether a message update is a real edit by its author. Discord also sends updates for +/// embed unfurls, pins and flag changes; those don't change the content or set a new edited time. +/// +public static class MessageEditClassifier +{ + /// + /// Without the previous version, an update only counts when it was edited this recently. + /// Older edited timestamps come from updates like pinning a message that was edited long ago. + /// + public static readonly TimeSpan UncachedEditMaxAge = TimeSpan.FromMinutes(2); + + /// The cached previous version, or null when it wasn't cached. + /// The version after the update. + /// Current time. + /// Edited time already logged for this message when uncached, to skip the unfurl update that follows an edit. + public static EditDecision Classify(MessageVersion? before, MessageVersion after, DateTimeOffset now, DateTimeOffset? lastLoggedEditedAt = null) + { + if (after.EditedAt == null) + return EditDecision.Ignore; + + if (before == null) + { + if (now - after.EditedAt.Value > UncachedEditMaxAge || after.EditedAt == lastLoggedEditedAt) + return EditDecision.Ignore; + return new EditDecision(EditKind.EditedUncached, true, [], []); + } + + var contentChanged = before.Content != after.Content; + var afterIds = after.Attachments.Select(a => a.Id).ToHashSet(); + var beforeIds = before.Attachments.Select(a => a.Id).ToHashSet(); + var removed = before.Attachments.Where(a => !afterIds.Contains(a.Id)).ToList(); + var added = after.Attachments.Where(a => !beforeIds.Contains(a.Id)).ToList(); + + if (!contentChanged && removed.Count == 0 && added.Count == 0) + return EditDecision.Ignore; + return new EditDecision(EditKind.Edited, contentChanged, removed, added); + } +} diff --git a/docs/features.md b/docs/features.md index 97885561..7c1ac7bc 100644 --- a/docs/features.md +++ b/docs/features.md @@ -8,7 +8,7 @@ | **Command Handling** | Text command + slash command routing, history tracking, prefix config | โ€” | `CommandHandlingService` | Core | | **Logging** | Multi-destination logging (console, file, Discord channel) with severity levels | โ€” | `LoggingService` | Core | | **Database** | PostgreSQL connection pooling, user/casino repositories | โ€” | `DatabaseService` | Core | -| **Audit Logging** | Background logging of moderator-relevant server events | โ€” | `AuditLogService` | Core | +| **Audit Logging** | Posts moderator log cards (Components V2, never ping) to the bot-announcement channel: deleted messages (author avatar, names, user ID, channel, content, attachment names, replies/forwards, including authors who have left), bulk deletes (one card grouped by author), real edits only (before/after and attachment changes; link unfurls, pins and other non-edit updates are ignored), and thread/forum-post deletions (name, parent, owner, tags, message count, starter post when cached). Who deleted a thread is shown when the bot has the optional **View Audit Log** permission. Also logs members leaving and nickname changes | โ€” | `AuditLogService` | Core | | **Unity Help** | Help forum thread management, auto-archive, canned responses, resources | `UnityHelpModule`, `UnityHelpInteractiveModule`, `GeneralHelpModule`, `CannedResponseModule`, `CannedInteractiveModule` | `UnityHelpService`, `CannedResponseService` | Core | | **Welcome** | New-member welcome messages | โ€” | `WelcomeService` | Core | | **Code Assistance** | Code-block formatting checks/reminders and code tips | `CodeTipModule` | `CodeCheckService` | Feature | From 1b94e230f61ccb4f6f1fd78fa42c19cb08bced9a Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 17:49:25 +1000 Subject: [PATCH 09/14] feat(moderation): add 4-image scam guard with delete and 24h timeout - Second message with exactly 4 images within 15 minutes deletes the user's matching messages and times them out - Report card lists the messages and re-uploads the images so moderators can check for false positives - Exempt bots, webhooks, admins, Manage Messages and the Moderator role; forwards are checked too - Audit log skips messages the guard deleted, since the report already covers them - ImageSpamGuard settings (off by default; enabled in k8s dev) --- .../Services/AuditLogCardsTests.cs | 25 +++ .../Services/ImageSpamTrackerTests.cs | 118 ++++++++++ .../Settings/BotSettingsValidationTests.cs | 37 ++++ DiscordBot/Program.cs | 4 + .../Moderation/BotDeletionRegistry.cs | 29 +++ .../Moderation/ImageSpamGuardService.cs | 209 ++++++++++++++++++ .../Services/Moderation/ImageSpamTracker.cs | 89 ++++++++ DiscordBot/Services/Server/AuditLogCards.cs | 51 +++++ DiscordBot/Services/Server/AuditLogService.cs | 10 +- DiscordBot/Settings/Deserialized/Settings.cs | 24 ++ DiscordBot/Settings/Settings.example.json | 7 + docs/features.md | 1 + k8s/dev/bot-config.yaml | 7 + k8s/prod/bot-config.yaml | 7 + 14 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 DiscordBot.Tests/Services/ImageSpamTrackerTests.cs create mode 100644 DiscordBot/Services/Moderation/BotDeletionRegistry.cs create mode 100644 DiscordBot/Services/Moderation/ImageSpamGuardService.cs create mode 100644 DiscordBot/Services/Moderation/ImageSpamTracker.cs diff --git a/DiscordBot.Tests/Services/AuditLogCardsTests.cs b/DiscordBot.Tests/Services/AuditLogCardsTests.cs index 8e8ec63d..e7eeaf78 100644 --- a/DiscordBot.Tests/Services/AuditLogCardsTests.cs +++ b/DiscordBot.Tests/Services/AuditLogCardsTests.cs @@ -193,4 +193,29 @@ public void DeletedThreadCard_UncachedNoAudit_Builds() Assert.Contains("Thread ID: 77", text); Assert.Contains("not have this thread cached", text); } + + [Fact] + public void ImageSpamReportCard_ShowsActionsAndCopiedImages() + { + var report = new AuditLogCards.ImageSpamReport( + User, 4, 15, + [ + new AuditLogCards.SpamMessage(10, 1001, "free nitro https://scam.example", ["1.png", "2.png", "3.png", "4.png"]), + new AuditLogCards.SpamMessage(20, 1002, new string('x', 5000), ["1.png", "2.png", "3.png", "4.png"]), + ], + 2, [], "โœ… 24 hours", ["attachment://spam-1.png", "attachment://spam-2.png"], null); + + var card = AuditLogCards.ImageSpamReportCard(report, Now); + var text = Text(card); + + Assert.True(text.Length <= AuditCard.DiscordTextLimit, $"Card text was {text.Length} chars"); + Assert.Contains("User ID: `123456789012345678`", text); + Assert.Contains("**Timeout:** โœ… 24 hours", text); + Assert.Contains("**Deleted:** 2 of 2 messages", text); + Assert.Contains("<#10>", text); + Assert.Contains("Message IDs: 1001, 1002", text); + + var gallery = Assert.Single(card.Components.OfType().Single().Components.OfType()); + Assert.Equal(2, gallery.Items.Count); + } } diff --git a/DiscordBot.Tests/Services/ImageSpamTrackerTests.cs b/DiscordBot.Tests/Services/ImageSpamTrackerTests.cs new file mode 100644 index 00000000..efe1f216 --- /dev/null +++ b/DiscordBot.Tests/Services/ImageSpamTrackerTests.cs @@ -0,0 +1,118 @@ +using DiscordBot.Services.Moderation; + +namespace DiscordBot.Tests.Services; + +public class ImageSpamTrackerTests +{ + private static readonly DateTimeOffset Start = new(2026, 10, 25, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan Window = TimeSpan.FromMinutes(15); + + private static ImageSpamHit Hit(ulong messageId, DateTimeOffset at, ulong channelId = 1) => + new(channelId, messageId, at, "", ["a.png", "b.png", "c.png", "d.png"]); + + [Fact] + public void FirstHit_Recorded() + { + var tracker = new ImageSpamTracker(Window); + Assert.Equal(ImageSpamOutcome.Recorded, tracker.Record(7, Hit(1, Start)).Outcome); + } + + [Fact] + public void SecondHitInsideWindow_TriggeredWithBothMessages() + { + var tracker = new ImageSpamTracker(Window); + tracker.Record(7, Hit(1, Start, channelId: 10)); + var result = tracker.Record(7, Hit(2, Start.AddMinutes(14), channelId: 20)); + + Assert.Equal(ImageSpamOutcome.Triggered, result.Outcome); + Assert.Equal([1UL, 2UL], result.Hits.Select(h => h.MessageId)); + } + + [Fact] + public void SecondHitAfterWindow_RecordedAgain() + { + var tracker = new ImageSpamTracker(Window); + tracker.Record(7, Hit(1, Start)); + var result = tracker.Record(7, Hit(2, Start.AddMinutes(16))); + + Assert.Equal(ImageSpamOutcome.Recorded, result.Outcome); + } + + [Fact] + public void HitsFromDifferentUsers_DoNotCombine() + { + var tracker = new ImageSpamTracker(Window); + tracker.Record(7, Hit(1, Start)); + Assert.Equal(ImageSpamOutcome.Recorded, tracker.Record(8, Hit(2, Start.AddMinutes(1))).Outcome); + } + + [Fact] + public void HitsAfterTrigger_AlreadyActionedUntilWindowEnds() + { + var tracker = new ImageSpamTracker(Window); + tracker.Record(7, Hit(1, Start)); + tracker.Record(7, Hit(2, Start.AddMinutes(1))); + + var third = tracker.Record(7, Hit(3, Start.AddMinutes(2))); + Assert.Equal(ImageSpamOutcome.AlreadyActioned, third.Outcome); + Assert.Equal([3UL], third.Hits.Select(h => h.MessageId)); + + // After the actioned period, the user starts from a clean slate. + Assert.Equal(ImageSpamOutcome.Recorded, tracker.Record(7, Hit(4, Start.AddMinutes(17))).Outcome); + } + + private static AttachmentShape Img(string name, string? type = "image/png") => new(name, type); + + [Fact] + public void Shape_FourImages_Matches() + { + Assert.True(ImageSpamShape.Matches([Img("1.png"), Img("2.png"), Img("3.jpg", "image/jpeg"), Img("4.webp", "image/webp")], null, 4)); + } + + [Theory] + [InlineData(3)] + [InlineData(5)] + public void Shape_WrongCount_DoesNotMatch(int count) + { + var attachments = Enumerable.Range(0, count).Select(i => Img($"{i}.png")).ToList(); + Assert.False(ImageSpamShape.Matches(attachments, null, 4)); + } + + [Fact] + public void Shape_MixedImagesAndZip_DoesNotMatch() + { + Assert.False(ImageSpamShape.Matches([Img("1.png"), Img("2.png"), Img("3.png"), Img("build.zip", "application/zip")], null, 4)); + } + + [Fact] + public void Shape_NoContentType_UsesExtension() + { + Assert.True(ImageSpamShape.Matches([Img("1.PNG", null), Img("2.jpeg", null), Img("3.gif", null), Img("4.webp", null)], null, 4)); + Assert.False(ImageSpamShape.Matches([Img("1.png", null), Img("2.png", null), Img("3.png", null), Img("4.txt", null)], null, 4)); + } + + [Fact] + public void Shape_ForwardWithFourImages_Matches() + { + var forwarded = Enumerable.Range(0, 4).Select(i => Img($"{i}.png")).ToList(); + Assert.True(ImageSpamShape.Matches([], forwarded, 4)); + } + + [Fact] + public void Shape_OwnAttachmentsTakePriorityOverForward() + { + var forwarded = Enumerable.Range(0, 4).Select(i => Img($"{i}.png")).ToList(); + Assert.False(ImageSpamShape.Matches([Img("only.png")], forwarded, 4)); + } + + [Fact] + public void Registry_ContainsUntilExpiry() + { + var registry = new BotDeletionRegistry(); + registry.Register([1, 2], Start); + + Assert.True(registry.Contains(1, Start.AddMinutes(1))); + Assert.False(registry.Contains(3, Start.AddMinutes(1))); + Assert.False(registry.Contains(1, Start + BotDeletionRegistry.Lifetime)); + } +} diff --git a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs index daa92f26..b6269490 100644 --- a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs +++ b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs @@ -126,4 +126,41 @@ public void UnityHelpEnabled_MissingTagResolved_Warning() var (_, warnings) = settings.Validate(); Assert.Contains(warnings, w => w.Contains("TagResolved")); } + + [Fact] + public void ImageSpamGuard_Defaults_NoWarnings() + { + var settings = CreateValid(); + settings.ImageSpamGuard = new ImageSpamGuardSettings { Enabled = true }; + var (_, warnings) = settings.Validate(); + Assert.DoesNotContain(warnings, w => w.Contains("ImageSpamGuard")); + Assert.Equal(4, settings.ImageSpamGuard.ImageCount); + Assert.Equal(15, settings.ImageSpamGuard.WindowMinutes); + Assert.Equal(24, settings.ImageSpamGuard.TimeoutHours); + } + + [Theory] + [InlineData(0, 15, 24, "ImageCount")] + [InlineData(4, 0, 24, "WindowMinutes")] + [InlineData(4, 15, 0, "TimeoutHours")] + [InlineData(4, 15, 673, "TimeoutHours")] + public void ImageSpamGuard_InvalidValues_Warning(int imageCount, int windowMinutes, int timeoutHours, string expected) + { + var settings = CreateValid(); + settings.ImageSpamGuard = new ImageSpamGuardSettings + { + Enabled = true, ImageCount = imageCount, WindowMinutes = windowMinutes, TimeoutHours = timeoutHours + }; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains($"ImageSpamGuard.{expected}")); + } + + [Fact] + public void ImageSpamGuard_Disabled_InvalidValuesIgnored() + { + var settings = CreateValid(); + settings.ImageSpamGuard = new ImageSpamGuardSettings { Enabled = false, ImageCount = 0 }; + var (_, warnings) = settings.Validate(); + Assert.DoesNotContain(warnings, w => w.Contains("ImageSpamGuard")); + } } diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index b3bf6eb0..fe192bbe 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -3,6 +3,7 @@ using Discord.Interactions; using Discord.WebSocket; using DiscordBot.Services; +using DiscordBot.Services.Moderation; using DiscordBot.Settings; using DiscordBot.Utils; using Microsoft.Extensions.DependencyInjection; @@ -83,6 +84,7 @@ private async Task MainAsync() _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); @@ -131,6 +133,8 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/DiscordBot/Services/Moderation/BotDeletionRegistry.cs b/DiscordBot/Services/Moderation/BotDeletionRegistry.cs new file mode 100644 index 00000000..226fd037 --- /dev/null +++ b/DiscordBot/Services/Moderation/BotDeletionRegistry.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; + +namespace DiscordBot.Services.Moderation; + +/// +/// Message IDs the bot is deleting itself and has already reported, so the audit log +/// doesn't post a second card for them. Entries expire after . +/// +public sealed class BotDeletionRegistry +{ + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); + + private readonly ConcurrentDictionary _expires = new(); + + public void Register(IEnumerable messageIds, DateTimeOffset now) + { + foreach (var id in messageIds) + _expires[id] = now + Lifetime; + + foreach (var (id, expires) in _expires) + { + if (now >= expires) + _expires.TryRemove(id, out _); + } + } + + public bool Contains(ulong messageId, DateTimeOffset now) => + _expires.TryGetValue(messageId, out var expires) && now < expires; +} diff --git a/DiscordBot/Services/Moderation/ImageSpamGuardService.cs b/DiscordBot/Services/Moderation/ImageSpamGuardService.cs new file mode 100644 index 00000000..103ee5d8 --- /dev/null +++ b/DiscordBot/Services/Moderation/ImageSpamGuardService.cs @@ -0,0 +1,209 @@ +using System.IO; +using System.Net.Http; +using Discord.Net; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Moderation; + +/// +/// Catches the common scam wave: the same account posting messages with exactly N images +/// (default 4) in quick succession. The first such message is only remembered; a second one +/// within the window deletes every remembered message, times the member out and posts a report. +/// +public class ImageSpamGuardService +{ + private const string ServiceName = "ImageSpamGuard"; + private const string AuditLogReason = "Image spam guard: repeated multi-image posts"; + // Stay under Discord's default 10 MB upload limit when copying images into the report. + private const long MaxCopyBytes = 8 * 1024 * 1024; + + private readonly DiscordSocketClient _client; + private readonly ILoggingService _loggingService; + private readonly BotDeletionRegistry _deletionRegistry; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ImageSpamGuardSettings _guardSettings; + private readonly ulong _moderatorRoleId; + private readonly ImageSpamTracker _tracker; + + public ImageSpamGuardService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService, + BotDeletionRegistry deletionRegistry, IHttpClientFactory httpClientFactory) + { + _client = client; + _loggingService = loggingService; + _deletionRegistry = deletionRegistry; + _httpClientFactory = httpClientFactory; + _guardSettings = settings.ImageSpamGuard; + _moderatorRoleId = settings.Roles.Moderator; + _tracker = new ImageSpamTracker(TimeSpan.FromMinutes(Math.Max(1, _guardSettings.WindowMinutes))); + + if (!_guardSettings.Enabled) + { + LoggingService.LogToConsole($"[{ServiceName}] Disabled in settings.", ExtendedLogSeverity.Info); + return; + } + + client.MessageReceived += EventGuard.Guarded(MessageReceived, nameof(MessageReceived)); + } + + private Task MessageReceived(SocketMessage message) + { + if (message is not SocketUserMessage userMessage || message.Author.IsUserBotOrWebhook()) + return Task.CompletedTask; + if (message.Channel is not SocketGuildChannel || message.Author is not SocketGuildUser member || IsExempt(member)) + return Task.CompletedTask; + + var forwarded = userMessage.ForwardedMessages?.Select(f => f.Message).FirstOrDefault(m => m != null); + var attachments = userMessage.Attachments.Count > 0 || forwarded == null ? userMessage.Attachments : forwarded.Attachments; + var shapes = attachments.Select(a => new AttachmentShape(a.Filename, a.ContentType)).ToList(); + if (!ImageSpamShape.Matches(shapes, null, _guardSettings.ImageCount)) + return Task.CompletedTask; + + var text = string.IsNullOrWhiteSpace(message.Content) ? forwarded?.Content ?? string.Empty : message.Content; + var hit = new ImageSpamHit(message.Channel.Id, message.Id, DateTimeOffset.UtcNow, text, shapes.Select(s => s.Filename).ToList()); + var result = _tracker.Record(member.Id, hit); + + switch (result.Outcome) + { + case ImageSpamOutcome.Triggered: + ActOnSpam(member, attachments, result.Hits).SafeFireAndForget(ServiceName); + break; + case ImageSpamOutcome.AlreadyActioned: + DeleteHits(result.Hits).SafeFireAndForget(ServiceName); + break; + } + return Task.CompletedTask; + } + + private bool IsExempt(SocketGuildUser member) => + member.GuildPermissions.Administrator + || member.GuildPermissions.ManageMessages + || (_moderatorRoleId != 0 && member.Roles.Any(r => r.Id == _moderatorRoleId)); + + private async Task ActOnSpam(SocketGuildUser member, IReadOnlyCollection lastAttachments, IReadOnlyList hits) + { + var timeoutResult = await TryTimeout(member); + // Copy the images before deleting, since their links stop working once the message is gone. + var (files, copyNote) = await CopyImages(lastAttachments); + var (deleted, failures) = await DeleteHits(hits); + + LoggingService.LogToConsole( + $"[{ServiceName}] Triggered for {member.GetUserLoggingString()}: deleted {deleted}/{hits.Count}, timeout: {timeoutResult}", + ExtendedLogSeverity.Warning); + + var report = new AuditLogCards.ImageSpamReport( + CardUser.From(member), + _guardSettings.ImageCount, + _guardSettings.WindowMinutes, + hits.Select(h => new AuditLogCards.SpamMessage(h.ChannelId, h.MessageId, h.Text, h.FileNames)).ToList(), + deleted, + failures, + timeoutResult, + files.Select(f => $"attachment://{f.FileName}").ToList(), + copyNote); + + try + { + await _loggingService.LogComponentsToChannel(AuditLogCards.ImageSpamReportCard(report, DateTimeOffset.UtcNow), files); + } + catch (HttpException e) when (files.Count > 0) + { + // Most likely the upload was too large: send the report without the images. + report = report with { CopiedImageUrls = [], CopyNote = $"Couldn't upload the copied images ({e.HttpCode})." }; + await _loggingService.LogComponentsToChannel(AuditLogCards.ImageSpamReportCard(report, DateTimeOffset.UtcNow)); + } + finally + { + foreach (var file in files) + file.Dispose(); + } + } + + private async Task TryTimeout(SocketGuildUser member) + { + var self = member.Guild.CurrentUser; + if (!self.GuildPermissions.ModerateMembers) + return "โŒ not timed out: the bot is missing the Moderate Members permission"; + if (member.Hierarchy >= self.Hierarchy) + return "โŒ not timed out: the member's top role is not below the bot's"; + + var duration = TimeSpan.FromHours(Math.Clamp(_guardSettings.TimeoutHours, 1, ImageSpamGuardSettings.MaxTimeoutHours)); + try + { + await member.SetTimeOutAsync(duration, new RequestOptions { AuditLogReason = AuditLogReason }); + return $"โœ… {duration.TotalHours:0} hours, until {AuditLogCards.Timestamp(DateTimeOffset.UtcNow + duration)}"; + } + catch (HttpException e) + { + return $"โŒ not timed out: Discord returned {(int)e.HttpCode} {e.Reason}".TrimEnd(); + } + } + + private async Task<(List Files, string? Note)> CopyImages(IReadOnlyCollection attachments) + { + var files = new List(); + long total = 0; + var skipped = 0; + using var http = _httpClientFactory.CreateClient(); + http.Timeout = TimeSpan.FromSeconds(15); + + foreach (var attachment in attachments) + { + if (total + attachment.Size > MaxCopyBytes) + { + skipped++; + continue; + } + try + { + var bytes = await http.GetByteArrayAsync(attachment.Url); + total += bytes.Length; + var extension = Path.GetExtension(attachment.Filename); + if (extension.Length is 0 or > 6) + extension = ".png"; + files.Add(new FileAttachment(new MemoryStream(bytes), $"spam-{files.Count + 1}{extension.ToLowerInvariant()}")); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException) + { + skipped++; + } + } + + var note = skipped == 0 ? null + : files.Count == 0 ? "Couldn't copy the images (too large or no longer available)." + : $"{skipped} image{(skipped == 1 ? "" : "s")} not copied (too large or no longer available)."; + return (files, note); + } + + private async Task<(int Deleted, List Failures)> DeleteHits(IReadOnlyList hits) + { + // Register first: the gateway delete event can arrive before the REST call returns. + _deletionRegistry.Register(hits.Select(h => h.MessageId), DateTimeOffset.UtcNow); + + var deleted = 0; + var failures = new List(); + foreach (var hit in hits) + { + if (_client.GetChannel(hit.ChannelId) is not IMessageChannel channel) + { + failures.Add($"<#{hit.ChannelId}> not found"); + continue; + } + try + { + await channel.DeleteMessageAsync(hit.MessageId, new RequestOptions { AuditLogReason = AuditLogReason }); + deleted++; + } + catch (HttpException e) when (e.DiscordCode == DiscordErrorCode.UnknownMessage) + { + // Already deleted by the author or a moderator. + deleted++; + } + catch (HttpException e) + { + failures.Add($"<#{hit.ChannelId}>: {(int)e.HttpCode}"); + } + } + return (deleted, failures); + } +} diff --git a/DiscordBot/Services/Moderation/ImageSpamTracker.cs b/DiscordBot/Services/Moderation/ImageSpamTracker.cs new file mode 100644 index 00000000..9293924a --- /dev/null +++ b/DiscordBot/Services/Moderation/ImageSpamTracker.cs @@ -0,0 +1,89 @@ +namespace DiscordBot.Services.Moderation; + +/// A message that matched the scam shape. +public sealed record ImageSpamHit(ulong ChannelId, ulong MessageId, DateTimeOffset At, string Text, IReadOnlyList FileNames); + +public enum ImageSpamOutcome +{ + /// First match in the window: remembered, no action. + Recorded, + /// Second match in the window: act on every hit returned. + Triggered, + /// The user was already actioned in this window: delete this message only. + AlreadyActioned, +} + +public sealed record ImageSpamResult(ImageSpamOutcome Outcome, IReadOnlyList Hits); + +/// +/// Counts scam-shaped messages per user in a sliding window. Thread-safe, in memory only. +/// +public sealed class ImageSpamTracker +{ + private readonly object _lock = new(); + private readonly Dictionary> _hits = new(); + private readonly Dictionary _actionedUntil = new(); + + public TimeSpan Window { get; } + + public ImageSpamTracker(TimeSpan window) => Window = window; + + public ImageSpamResult Record(ulong userId, ImageSpamHit hit) + { + lock (_lock) + { + Prune(hit.At); + + if (_actionedUntil.TryGetValue(userId, out var until) && hit.At < until) + return new ImageSpamResult(ImageSpamOutcome.AlreadyActioned, [hit]); + + if (!_hits.TryGetValue(userId, out var hits)) + _hits[userId] = hits = []; + hits.Add(hit); + + if (hits.Count < 2) + return new ImageSpamResult(ImageSpamOutcome.Recorded, [hit]); + + _hits.Remove(userId); + _actionedUntil[userId] = hit.At + Window; + return new ImageSpamResult(ImageSpamOutcome.Triggered, hits.ToList()); + } + } + + private void Prune(DateTimeOffset now) + { + foreach (var (userId, hits) in _hits.ToList()) + { + hits.RemoveAll(h => now - h.At > Window); + if (hits.Count == 0) + _hits.Remove(userId); + } + foreach (var (userId, until) in _actionedUntil.ToList()) + { + if (now >= until) + _actionedUntil.Remove(userId); + } + } +} + +public sealed record AttachmentShape(string Filename, string? ContentType); + +/// The scam shape: exactly N attachments, all images (or a forward of such a message). +public static class ImageSpamShape +{ + private static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp"]; + + public static bool IsImage(AttachmentShape attachment) => + attachment.ContentType != null + ? attachment.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + : ImageExtensions.Any(ext => attachment.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase)); + + /// The message's own attachments. + /// Attachments of the first forwarded message, used when the message has none. + /// Exact number of images that makes a match. + public static bool Matches(IReadOnlyList attachments, IReadOnlyList? forwarded, int imageCount) + { + var set = attachments.Count > 0 ? attachments : forwarded ?? []; + return set.Count == imageCount && set.All(IsImage); + } +} diff --git a/DiscordBot/Services/Server/AuditLogCards.cs b/DiscordBot/Services/Server/AuditLogCards.cs index d8f6d215..c333fae2 100644 --- a/DiscordBot/Services/Server/AuditLogCards.cs +++ b/DiscordBot/Services/Server/AuditLogCards.cs @@ -391,4 +391,55 @@ public static MessageComponent DeletedThreadCard(DeletedThread thread, DateTimeO return card.WithFooter(string.Join(" ยท ", footer)).Build(); } + + public static readonly Color SpamGuardColor = new(230, 60, 60); + private const int MaxSpamExcerptLength = 300; + + public sealed record SpamMessage(ulong ChannelId, ulong MessageId, string Text, IReadOnlyList FileNames); + + public sealed record ImageSpamReport( + CardUser User, + int ImageCount, + int WindowMinutes, + IReadOnlyList Messages, + int Deleted, + IReadOnlyList DeleteFailures, + string TimeoutResult, + IReadOnlyList CopiedImageUrls, + string? CopyNote); + + public static MessageComponent ImageSpamReportCard(ImageSpamReport report, DateTimeOffset at) + { + var card = new AuditCard(SpamGuardColor) + .WithHeader("๐Ÿšจ Image spam guard triggered", report.User, null, + $"Posted {report.Messages.Count} messages with exactly {report.ImageCount} images within {report.WindowMinutes} minutes.", + $"**Timeout:** {report.TimeoutResult}", + $"**Deleted:** {report.Deleted} of {report.Messages.Count} messages" + + (report.DeleteFailures.Count > 0 ? $" ({string.Join("; ", report.DeleteFailures)})" : "")); + + var lines = new List { "**Messages**" }; + foreach (var message in report.Messages) + { + lines.Add($"- <#{message.ChannelId}> ยท " + string.Join(", ", message.FileNames.Select(f => $"`{f.Replace("`", "'")}`"))); + if (!string.IsNullOrWhiteSpace(message.Text)) + lines.Add("> " + Fit(string.Join(' ', message.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)))); + } + card.AddLines(lines); + + if (report.CopiedImageUrls.Count > 0) + { + card.AddSeparator() + .AddText("-# Images copied from the last message, to check this isn't a false positive:") + .AddMedia(report.CopiedImageUrls); + } + if (report.CopyNote != null) + card.AddText($"-# {report.CopyNote}"); + + return card + .WithFooter($"Message IDs: {string.Join(", ", report.Messages.Select(m => m.MessageId))} ยท {Timestamp(at)} ยท " + + "If this was a false positive, remove the timeout from the member's profile") + .Build(); + + static string Fit(string text) => AuditCard.Fit(text, MaxSpamExcerptLength, "โ€ฆ"); + } } diff --git a/DiscordBot/Services/Server/AuditLogService.cs b/DiscordBot/Services/Server/AuditLogService.cs index 23358db6..8f7a607f 100644 --- a/DiscordBot/Services/Server/AuditLogService.cs +++ b/DiscordBot/Services/Server/AuditLogService.cs @@ -3,6 +3,7 @@ using Discord.Net; using Discord.Rest; using Discord.WebSocket; +using DiscordBot.Services.Moderation; using DiscordBot.Settings; namespace DiscordBot.Services.Server; @@ -12,6 +13,7 @@ public class AuditLogService private readonly ILoggingService _loggingService; private readonly DiscordSocketClient _client; private readonly BotSettings _settings; + private readonly BotDeletionRegistry _botDeletions; // Uncached edits already logged (message ID -> edited time), so the unfurl update after an edit isn't logged twice. private readonly ConcurrentDictionary _uncachedEditsLogged = new(); @@ -19,8 +21,9 @@ public class AuditLogService private readonly IMessageChannel? _botAnnouncementChannel = null!; - public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService) + public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService, BotDeletionRegistry botDeletions) { + _botDeletions = botDeletions; _loggingService = loggingService; _client = client; _settings = settings; @@ -38,7 +41,8 @@ public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggin private async Task MessageDeleted(Cacheable message, Cacheable channel) { - if (channel.Id == _botAnnouncementChannel?.Id) + // Messages removed by the spam guard are already in its report. + if (channel.Id == _botAnnouncementChannel?.Id || _botDeletions.Contains(message.Id, DateTimeOffset.UtcNow)) return; if (message.HasValue == false) @@ -71,6 +75,8 @@ private async Task MessagesBulkDeleted(IReadOnlyCollection !_botDeletions.Contains(m.Id, now)).ToList(); var cached = messages.Where(m => m.HasValue).Select(m => m.Value).OrderBy(m => m.Timestamp).ToList(); var uncachedCount = messages.Count - cached.Count; var listed = cached diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 6e9ea102..e9084098 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -22,6 +22,7 @@ public class BotSettings public BirthdaySettings Birthday { get; set; } = new(); public ApiKeySettings ApiKeys { get; set; } = new(); public FunCommandSettings FunCommands { get; set; } = new(); + public ImageSpamGuardSettings ImageSpamGuard { get; set; } = new(); public (List Errors, List Warnings) Validate() { @@ -74,6 +75,16 @@ public class BotSettings if (Casino.Enabled && Casino.StartingTokens < 0) errors.Add("Casino.StartingTokens is negative"); + if (ImageSpamGuard.Enabled) + { + if (ImageSpamGuard.ImageCount < 1) + warnings.Add("ImageSpamGuard.ImageCount must be at least 1"); + if (ImageSpamGuard.WindowMinutes < 1) + warnings.Add("ImageSpamGuard.WindowMinutes must be at least 1"); + if (ImageSpamGuard.TimeoutHours is < 1 or > ImageSpamGuardSettings.MaxTimeoutHours) + warnings.Add($"ImageSpamGuard.TimeoutHours must be between 1 and {ImageSpamGuardSettings.MaxTimeoutHours} (Discord's 28-day limit)"); + } + return (errors, warnings); } @@ -162,6 +173,19 @@ public class FunCommandSettings public List SlapFails { get; set; } = []; } +public class ImageSpamGuardSettings +{ + /// Discord timeouts can't be longer than 28 days. + public const int MaxTimeoutHours = 28 * 24; + + public bool Enabled { get; set; } = false; + /// A message counts when it has exactly this many attachments, all images. + public int ImageCount { get; set; } = 4; + /// A second matching message from the same user within this window triggers the guard. + public int WindowMinutes { get; set; } = 15; + public int TimeoutHours { get; set; } = 24; +} + public class ChannelInfo { public string Desc { get; set; } = string.Empty; diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index 3871ab2b..bc808b94 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -73,5 +73,12 @@ "SlapObjectsTable": null, "SlapChoices": [], "SlapFails": [] + }, + + "ImageSpamGuard": { + "Enabled": false, + "ImageCount": 4, + "WindowMinutes": 15, + "TimeoutHours": 24 } } \ No newline at end of file diff --git a/docs/features.md b/docs/features.md index 7c1ac7bc..8c7ef7fd 100644 --- a/docs/features.md +++ b/docs/features.md @@ -30,6 +30,7 @@ | **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday show [count] [user]` lists upcoming birthdays or a specific member's birthday, `/bday set`, `/bday del`, the **View Birthday**, **Set Birthday**, and **Remove Birthday** user context-menu commands โ€” removal asks for confirmation via buttons, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | | **Recruitment** | Configurable recruitment workflow (toggleable) | โ€” | `RecruitService` | Feature | | **Release Feeds** | RSS feed parsing and Unity release-notes tracking | โ€” | `FeedService`, `ReleaseNotesParser` | Feature | +| **Image Spam Guard** | Stops the common scam wave of messages with exactly 4 images (text optional, forwards included). The first such message from a member is only remembered; a second one within the window deletes every remembered message, times the member out, and posts one report to the bot-announcement channel with the messages, channels, timeout result and a copy of the images so moderators can check for false positives. Bots, webhooks, admins, members with Manage Messages and the Moderator role are exempt. State is in memory only. Settings: `ImageSpamGuard` (`Enabled`, off by default; `ImageCount` 4; `WindowMinutes` 15; `TimeoutHours` 24). Needs **Manage Messages** and **Moderate Members**, and the bot's top role must be above the member's | โ€” | `ImageSpamGuardService` | Feature | | **@everyone Scold** | Warns users who use `@everyone`/`@here` without permission | โ€” | `EveryoneScoldService` | Feature | | **Miku** | Playful auto-reply when Hatsune Miku is mentioned | โ€” | `MikuService` | Feature | | **Karma Reset** | Scheduled periodic karma resets | โ€” | `KarmaResetService` | Maintenance | diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index 1c9f60e7..77f4f71e 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -81,5 +81,12 @@ data: "Birthday": { "Enabled": true, "CheckIntervalMinutes": 1 + }, + + "ImageSpamGuard": { + "Enabled": true, + "ImageCount": 4, + "WindowMinutes": 15, + "TimeoutHours": 24 } } diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index de4335cd..55c3b6ce 100644 --- a/k8s/prod/bot-config.yaml +++ b/k8s/prod/bot-config.yaml @@ -88,5 +88,12 @@ data: "Birthday": { "Enabled": true, "CheckIntervalMinutes": 60 + }, + + "ImageSpamGuard": { + "Enabled": false, + "ImageCount": 4, + "WindowMinutes": 15, + "TimeoutHours": 24 } } From ac8b5cd8a82af264278fa6e85ae580008b02dd20 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 18:44:20 +1000 Subject: [PATCH 10/14] feat(config): update IntroductionWatch settings and enable ImageSpamGuard --- k8s/dev/bot-config.yaml | 2 +- k8s/prod/bot-config.yaml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index a95160fd..d3f133ab 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -68,7 +68,7 @@ data: "IntroductionWatch": { "Enabled": true, - "DelayMinutes": 1 + "DelayMinutes": 2 }, "FunCommands": { diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index 55c3b6ce..849e7a21 100644 --- a/k8s/prod/bot-config.yaml +++ b/k8s/prod/bot-config.yaml @@ -67,6 +67,11 @@ data: "TipImageDirectory": "tips" }, + "IntroductionWatch": { + "Enabled": true, + "DelayMinutes": 2 + }, + "FunCommands": { "SlapObjectsTable": "Settings/udc-slap.txt", "SlapChoices": [ @@ -91,7 +96,7 @@ data: }, "ImageSpamGuard": { - "Enabled": false, + "Enabled": true, "ImageCount": 4, "WindowMinutes": 15, "TimeoutHours": 24 From d34df8a8623625288d5f2e5cea912e38ebf0e29d Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 20:31:15 +1000 Subject: [PATCH 11/14] fix(moderation): require two channels and a positive image count for spam guard - Trigger only when scam-shaped posts span at least two distinct channels - Never match when ImageCount is below 1, so text-only messages cannot trigger - Clarify the ImageCount warning and update the feature docs --- .../Services/ImageSpamTrackerTests.cs | 41 ++++++++++++++++--- .../Moderation/ImageSpamGuardService.cs | 5 ++- .../Services/Moderation/ImageSpamTracker.cs | 13 +++--- DiscordBot/Settings/Deserialized/Settings.cs | 2 +- docs/features.md | 2 +- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/DiscordBot.Tests/Services/ImageSpamTrackerTests.cs b/DiscordBot.Tests/Services/ImageSpamTrackerTests.cs index efe1f216..3c78c2f6 100644 --- a/DiscordBot.Tests/Services/ImageSpamTrackerTests.cs +++ b/DiscordBot.Tests/Services/ImageSpamTrackerTests.cs @@ -32,12 +32,33 @@ public void SecondHitInsideWindow_TriggeredWithBothMessages() public void SecondHitAfterWindow_RecordedAgain() { var tracker = new ImageSpamTracker(Window); - tracker.Record(7, Hit(1, Start)); - var result = tracker.Record(7, Hit(2, Start.AddMinutes(16))); + tracker.Record(7, Hit(1, Start, channelId: 10)); + var result = tracker.Record(7, Hit(2, Start.AddMinutes(16), channelId: 20)); Assert.Equal(ImageSpamOutcome.Recorded, result.Outcome); } + [Fact] + public void HitsInSameChannel_DoNotTrigger() + { + var tracker = new ImageSpamTracker(Window); + tracker.Record(7, Hit(1, Start, channelId: 10)); + Assert.Equal(ImageSpamOutcome.Recorded, tracker.Record(7, Hit(2, Start.AddMinutes(1), channelId: 10)).Outcome); + Assert.Equal(ImageSpamOutcome.Recorded, tracker.Record(7, Hit(3, Start.AddMinutes(2), channelId: 10)).Outcome); + } + + [Fact] + public void SameChannelHitsThenOtherChannel_TriggeredWithAllMessages() + { + var tracker = new ImageSpamTracker(Window); + tracker.Record(7, Hit(1, Start, channelId: 10)); + tracker.Record(7, Hit(2, Start.AddMinutes(1), channelId: 10)); + var result = tracker.Record(7, Hit(3, Start.AddMinutes(2), channelId: 20)); + + Assert.Equal(ImageSpamOutcome.Triggered, result.Outcome); + Assert.Equal([1UL, 2UL, 3UL], result.Hits.Select(h => h.MessageId)); + } + [Fact] public void HitsFromDifferentUsers_DoNotCombine() { @@ -50,10 +71,10 @@ public void HitsFromDifferentUsers_DoNotCombine() public void HitsAfterTrigger_AlreadyActionedUntilWindowEnds() { var tracker = new ImageSpamTracker(Window); - tracker.Record(7, Hit(1, Start)); - tracker.Record(7, Hit(2, Start.AddMinutes(1))); + tracker.Record(7, Hit(1, Start, channelId: 10)); + tracker.Record(7, Hit(2, Start.AddMinutes(1), channelId: 20)); - var third = tracker.Record(7, Hit(3, Start.AddMinutes(2))); + var third = tracker.Record(7, Hit(3, Start.AddMinutes(2), channelId: 30)); Assert.Equal(ImageSpamOutcome.AlreadyActioned, third.Outcome); Assert.Equal([3UL], third.Hits.Select(h => h.MessageId)); @@ -78,6 +99,16 @@ public void Shape_WrongCount_DoesNotMatch(int count) Assert.False(ImageSpamShape.Matches(attachments, null, 4)); } + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Shape_NonPositiveCount_NeverMatches(int imageCount) + { + // A text-only message has zero attachments, so a count of 0 must not match it. + Assert.False(ImageSpamShape.Matches([], null, imageCount)); + Assert.False(ImageSpamShape.Matches([], [], imageCount)); + } + [Fact] public void Shape_MixedImagesAndZip_DoesNotMatch() { diff --git a/DiscordBot/Services/Moderation/ImageSpamGuardService.cs b/DiscordBot/Services/Moderation/ImageSpamGuardService.cs index 103ee5d8..4e7e9e03 100644 --- a/DiscordBot/Services/Moderation/ImageSpamGuardService.cs +++ b/DiscordBot/Services/Moderation/ImageSpamGuardService.cs @@ -8,8 +8,9 @@ namespace DiscordBot.Services.Moderation; /// /// Catches the common scam wave: the same account posting messages with exactly N images -/// (default 4) in quick succession. The first such message is only remembered; a second one -/// within the window deletes every remembered message, times the member out and posts a report. +/// (default 4) in quick succession. The first such message is only remembered; another one in a +/// different channel within the window deletes every remembered message, times the member out and +/// posts a report. /// public class ImageSpamGuardService { diff --git a/DiscordBot/Services/Moderation/ImageSpamTracker.cs b/DiscordBot/Services/Moderation/ImageSpamTracker.cs index 9293924a..22955962 100644 --- a/DiscordBot/Services/Moderation/ImageSpamTracker.cs +++ b/DiscordBot/Services/Moderation/ImageSpamTracker.cs @@ -5,9 +5,9 @@ public sealed record ImageSpamHit(ulong ChannelId, ulong MessageId, DateTimeOffs public enum ImageSpamOutcome { - /// First match in the window: remembered, no action. + /// Matches so far are all in one channel: remembered, no action. Recorded, - /// Second match in the window: act on every hit returned. + /// Matches in at least two channels within the window: act on every hit returned. Triggered, /// The user was already actioned in this window: delete this message only. AlreadyActioned, @@ -16,7 +16,8 @@ public enum ImageSpamOutcome public sealed record ImageSpamResult(ImageSpamOutcome Outcome, IReadOnlyList Hits); /// -/// Counts scam-shaped messages per user in a sliding window. Thread-safe, in memory only. +/// Counts scam-shaped messages per user in a sliding window and triggers once they span two or +/// more channels, so repeated screenshots in one help thread don't count. Thread-safe, in memory only. /// public sealed class ImageSpamTracker { @@ -41,7 +42,7 @@ public ImageSpamResult Record(ulong userId, ImageSpamHit hit) _hits[userId] = hits = []; hits.Add(hit); - if (hits.Count < 2) + if (hits.Select(h => h.ChannelId).Distinct().Count() < 2) return new ImageSpamResult(ImageSpamOutcome.Recorded, [hit]); _hits.Remove(userId); @@ -80,9 +81,11 @@ public static bool IsImage(AttachmentShape attachment) => /// The message's own attachments. /// Attachments of the first forwarded message, used when the message has none. - /// Exact number of images that makes a match. + /// Exact number of images that makes a match. Never matches if below 1. public static bool Matches(IReadOnlyList attachments, IReadOnlyList? forwarded, int imageCount) { + if (imageCount < 1) + return false; var set = attachments.Count > 0 ? attachments : forwarded ?? []; return set.Count == imageCount && set.All(IsImage); } diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 52064e0a..9e46b052 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -82,7 +82,7 @@ public class BotSettings if (ImageSpamGuard.Enabled) { if (ImageSpamGuard.ImageCount < 1) - warnings.Add("ImageSpamGuard.ImageCount must be at least 1"); + warnings.Add("ImageSpamGuard.ImageCount must be at least 1 โ€” the guard will not act until it is fixed"); if (ImageSpamGuard.WindowMinutes < 1) warnings.Add("ImageSpamGuard.WindowMinutes must be at least 1"); if (ImageSpamGuard.TimeoutHours is < 1 or > ImageSpamGuardSettings.MaxTimeoutHours) diff --git a/docs/features.md b/docs/features.md index 91d53d35..1ccc41cb 100644 --- a/docs/features.md +++ b/docs/features.md @@ -31,7 +31,7 @@ | **Recruitment** | Configurable recruitment workflow (toggleable) | โ€” | `RecruitService` | Feature | | **Introduction Prompt** | Keeps a short "say hello" prompt as the latest message in Introductions: after a member posts and the channel has been quiet for `IntroductionWatch.DelayMinutes`, posts a new prompt (no pings) and deletes the old one (toggleable via `IntroductionWatch.Enabled`, off by default). Needs View Channel, Send Messages and Read Message History in Introductions | โ€” | `IntroductionWatcherService` | Feature | | **Release Feeds** | RSS feed parsing and Unity release-notes tracking | โ€” | `FeedService`, `ReleaseNotesParser` | Feature | -| **Image Spam Guard** | Stops the common scam wave of messages with exactly 4 images (text optional, forwards included). The first such message from a member is only remembered; a second one within the window deletes every remembered message, times the member out, and posts one report to the bot-announcement channel with the messages, channels, timeout result and a copy of the images so moderators can check for false positives. Bots, webhooks, admins, members with Manage Messages and the Moderator role are exempt. State is in memory only. Settings: `ImageSpamGuard` (`Enabled`, off by default; `ImageCount` 4; `WindowMinutes` 15; `TimeoutHours` 24). Needs **Manage Messages** and **Moderate Members**, and the bot's top role must be above the member's | โ€” | `ImageSpamGuardService` | Feature | +| **Image Spam Guard** | Stops the common scam wave of messages with exactly 4 images (text optional, forwards included). The first such message from a member is only remembered; another one in a different channel within the window deletes every remembered message, times the member out, and posts one report to the bot-announcement channel with the messages, channels, timeout result and a copy of the images so moderators can check for false positives. Bots, webhooks, admins, members with Manage Messages and the Moderator role are exempt. State is in memory only. Settings: `ImageSpamGuard` (`Enabled`, off by default; `ImageCount` 4; `WindowMinutes` 15; `TimeoutHours` 24). Needs **Manage Messages** and **Moderate Members**, and the bot's top role must be above the member's | โ€” | `ImageSpamGuardService` | Feature | | **@everyone Scold** | Warns users who use `@everyone`/`@here` without permission | โ€” | `EveryoneScoldService` | Feature | | **Miku** | Playful auto-reply when Hatsune Miku is mentioned | โ€” | `MikuService` | Feature | | **Karma Reset** | Scheduled periodic karma resets | โ€” | `KarmaResetService` | Maintenance | From 80294451df056762966bfbb169a56993a64bc19f Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 20:31:15 +1000 Subject: [PATCH 12/14] fix(intro-watch): retry failed prompts and keep logging failures from stopping the loop - Add IntroductionPromptSchedule.Retry, which re-arms without overriding a newer Touch - Retry transient post failures after one minute; skip retries on 4xx errors - Fall back to console when channel/file warning logging throws --- .../IntroductionPromptScheduleTests.cs | 25 +++++++++++++ .../Server/IntroductionPromptSchedule.cs | 9 +++++ .../Server/IntroductionWatcherService.cs | 35 +++++++++++++++---- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs b/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs index 22b008e2..e7de0466 100644 --- a/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs +++ b/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs @@ -73,4 +73,29 @@ public void TryTakeDue_ClearsOnlyWhenDue() Assert.Null(schedule.DueUtc); Assert.False(schedule.TryTakeDue(Start.AddMinutes(6))); } + + [Fact] + public void Retry_AfterTake_SchedulesRetry() + { + var schedule = new IntroductionPromptSchedule(5); + schedule.Touch(Start); + Assert.True(schedule.TryTakeDue(Start.AddMinutes(5))); + + schedule.Retry(Start.AddMinutes(6)); + Assert.Equal(Start.AddMinutes(6), schedule.DueUtc); + Assert.True(schedule.TryTakeDue(Start.AddMinutes(6))); + } + + [Fact] + public void Retry_DoesNotOverrideNewerTouch() + { + var schedule = new IntroductionPromptSchedule(5); + schedule.Touch(Start); + Assert.True(schedule.TryTakeDue(Start.AddMinutes(5))); + + // A member posted while the failed prompt was being sent. + schedule.Touch(Start.AddMinutes(5)); + schedule.Retry(Start.AddMinutes(6)); + Assert.Equal(Start.AddMinutes(10), schedule.DueUtc); + } } diff --git a/DiscordBot/Services/Server/IntroductionPromptSchedule.cs b/DiscordBot/Services/Server/IntroductionPromptSchedule.cs index 53287bf3..6e05f5c4 100644 --- a/DiscordBot/Services/Server/IntroductionPromptSchedule.cs +++ b/DiscordBot/Services/Server/IntroductionPromptSchedule.cs @@ -45,6 +45,15 @@ public bool TryTakeDue(DateTime nowUtc) } } + /// + /// Schedules a retry after a failed post. Does nothing if a already set a + /// due time, so a newer message keeps its own debounce. + /// + public void Retry(DateTime retryAtUtc) + { + lock (_lock) _dueUtc ??= retryAtUtc; + } + public void Clear() { lock (_lock) _dueUtc = null; diff --git a/DiscordBot/Services/Server/IntroductionWatcherService.cs b/DiscordBot/Services/Server/IntroductionWatcherService.cs index 1c492068..b439f62a 100644 --- a/DiscordBot/Services/Server/IntroductionWatcherService.cs +++ b/DiscordBot/Services/Server/IntroductionWatcherService.cs @@ -15,6 +15,7 @@ public class IntroductionWatcherService private const string ServiceName = "IntroductionWatcherService"; private const int HistoryScanLimit = 50; private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); + private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1); private static readonly Color AccentColour = new(0x12D687); private readonly DiscordSocketClient _client; @@ -108,12 +109,34 @@ private async Task RunAsync() } catch (Exception e) when (e is not OperationCanceledException) { - await _loggingService.LogChannelAndFile( - $"[{ServiceName}] Failed to post the introduction prompt: {e.Message}", ExtendedLogSeverity.Warning); + // Retry outages and timeouts; a 4xx such as missing permissions won't fix itself. + var retry = e is not HttpException http || (int)http.HttpCode >= 500; + if (retry) + _schedule.Retry(DateTime.UtcNow + RetryDelay); + var next = retry ? $"retrying in {RetryDelay.TotalMinutes:0} minute(s)" : "not retrying"; + await LogWarningAsync($"Failed to post the introduction prompt ({next}): {e.Message}"); } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // Bot is shutting down. + } + } + + // Logging to the channel can fail during the same outage that caused the warning, so it must + // never throw out of the background loop. + private async Task LogWarningAsync(string message) + { + message = $"[{ServiceName}] {message}"; + try + { + await _loggingService.LogChannelAndFile(message, ExtendedLogSeverity.Warning); + } + catch (Exception e) + { + LoggingService.LogToConsole($"{message} (could not log to channel/file: {e.Message})", ExtendedLogSeverity.Warning); + } } /// @@ -144,8 +167,7 @@ private async Task RecoverFromHistoryAsync() } catch (Exception e) when (e is not OperationCanceledException) { - await _loggingService.LogChannelAndFile( - $"[{ServiceName}] Could not read Introductions history on startup: {e.Message}", ExtendedLogSeverity.Warning); + await LogWarningAsync($"Could not read Introductions history on startup: {e.Message}"); } } @@ -182,8 +204,7 @@ private async Task DeleteMessageAsync(ulong messageId) } catch (Exception e) { - await _loggingService.LogChannelAndFile( - $"[{ServiceName}] Failed to delete old introduction prompt {messageId}: {e.Message}", ExtendedLogSeverity.Warning); + await LogWarningAsync($"Failed to delete old introduction prompt {messageId}: {e.Message}"); } } } From 6b9a166522d158dbc6f10c697ceec0c0d9d00e4c Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 20:31:15 +1000 Subject: [PATCH 13/14] build: drop net10.0 target so the .NET 8 Docker build can restore --- DiscordBot/DiscordBot.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 8ed41567..438cbcc1 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -2,7 +2,6 @@ Exe net8.0 - net8.0;net10.0 12 enable true From cf9420d0a9b378c58aaf88b9dc0c785d7ec5ddfa Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 25 Oct 2026 20:31:15 +1000 Subject: [PATCH 14/14] refactor(audit): simplify in-server check in AuthorCard --- DiscordBot/Services/Server/AuditLogService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordBot/Services/Server/AuditLogService.cs b/DiscordBot/Services/Server/AuditLogService.cs index 8f7a607f..ac0c29f9 100644 --- a/DiscordBot/Services/Server/AuditLogService.cs +++ b/DiscordBot/Services/Server/AuditLogService.cs @@ -96,7 +96,7 @@ await _loggingService.LogComponentsToChannel( private static CardUser AuthorCard(IUser author, IGuild? guild) { // Deletions from people who left or were banned are what scam waves look like, so call it out. - var inServer = guild is SocketGuild socketGuild ? socketGuild.GetUser(author.Id) != null : true; + var inServer = guild is not SocketGuild socketGuild || socketGuild.GetUser(author.Id) != null; return CardUser.From(author, inServer ? null : "no longer in the server"); }