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
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.Tests/Services/AuditLogCardsTests.cs b/DiscordBot.Tests/Services/AuditLogCardsTests.cs
new file mode 100644
index 00000000..e7eeaf78
--- /dev/null
+++ b/DiscordBot.Tests/Services/AuditLogCardsTests.cs
@@ -0,0 +1,221 @@
+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);
+ }
+
+ [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);
+ }
+
+ [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..3c78c2f6
--- /dev/null
+++ b/DiscordBot.Tests/Services/ImageSpamTrackerTests.cs
@@ -0,0 +1,149 @@
+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, 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()
+ {
+ 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, channelId: 10));
+ tracker.Record(7, Hit(2, Start.AddMinutes(1), channelId: 20));
+
+ 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));
+
+ // 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));
+ }
+
+ [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()
+ {
+ 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/Services/IntroductionPromptScheduleTests.cs b/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs
new file mode 100644
index 00000000..e7de0466
--- /dev/null
+++ b/DiscordBot.Tests/Services/IntroductionPromptScheduleTests.cs
@@ -0,0 +1,101 @@
+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)));
+ }
+
+ [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.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.Tests/Settings/BotSettingsValidationTests.cs b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs
index daa92f26..48758ed1 100644
--- a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs
+++ b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs
@@ -126,4 +126,59 @@ 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"));
+ }
+
+ [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/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj
index 8abb6470..438cbcc1 100644
--- a/DiscordBot/DiscordBot.csproj
+++ b/DiscordBot/DiscordBot.csproj
@@ -21,4 +21,4 @@
-
\ No newline at end of file
+
diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs
index b3bf6eb0..ac76b8b7 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,7 +84,9 @@ private async Task MainAsync()
_services.GetRequiredService();
_services.GetRequiredService();
_services.GetRequiredService();
+ _services.GetRequiredService();
_services.GetRequiredService();
+ _services.GetRequiredService();
_services.GetRequiredService();
_services.GetRequiredService();
_services.GetRequiredService();
@@ -122,6 +125,7 @@ private IServiceProvider ConfigureServices() =>
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton()
@@ -131,6 +135,8 @@ private IServiceProvider ConfigureServices() =>
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton()
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/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..4e7e9e03
--- /dev/null
+++ b/DiscordBot/Services/Moderation/ImageSpamGuardService.cs
@@ -0,0 +1,210 @@
+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; another one in a
+/// different channel 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..22955962
--- /dev/null
+++ b/DiscordBot/Services/Moderation/ImageSpamTracker.cs
@@ -0,0 +1,92 @@
+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
+{
+ /// Matches so far are all in one channel: remembered, no action.
+ Recorded,
+ /// 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,
+}
+
+public sealed record ImageSpamResult(ImageSpamOutcome Outcome, IReadOnlyList Hits);
+
+///
+/// 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
+{
+ 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.Select(h => h.ChannelId).Distinct().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. 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/Services/Server/AuditLogCards.cs b/DiscordBot/Services/Server/AuditLogCards.cs
new file mode 100644
index 00000000..c333fae2
--- /dev/null
+++ b/DiscordBot/Services/Server/AuditLogCards.cs
@@ -0,0 +1,445 @@
+using Discord.WebSocket;
+
+namespace DiscordBot.Services.Server;
+
+/// 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);
+}
+
+/// 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, ulong Id = 0);
+
+///
+/// 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.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)
+ 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();
+ }
+
+ 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();
+ }
+
+ 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 a2c7dc2e..ac0c29f9 100644
--- a/DiscordBot/Services/Server/AuditLogService.cs
+++ b/DiscordBot/Services/Server/AuditLogService.cs
@@ -1,5 +1,9 @@
+using System.Collections.Concurrent;
using System.Globalization;
+using Discord.Net;
+using Discord.Rest;
using Discord.WebSocket;
+using DiscordBot.Services.Moderation;
using DiscordBot.Settings;
namespace DiscordBot.Services.Server;
@@ -7,19 +11,27 @@ namespace DiscordBot.Services.Server;
public class AuditLogService
{
private readonly ILoggingService _loggingService;
+ private readonly DiscordSocketClient _client;
+ private readonly BotSettings _settings;
+ private readonly BotDeletionRegistry _botDeletions;
- private const int MaxMessageLength = 800;
- private static readonly Color DeletedMessageColor = new(200, 128, 128);
- 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!;
- 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;
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));
@@ -29,86 +41,205 @@ public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggin
private async Task MessageDeleted(Cacheable message, Cacheable channel)
{
+ // 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)
{
- 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];
-
- 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();
-
- await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed);
+ IChannel? cachedChannel = channel.HasValue ? channel.Value : _client.GetChannel(channel.Id);
+ if (cachedChannel is IPrivateChannel)
+ return;
+ var guild = (cachedChannel as IGuildChannel)?.Guild;
+
+ var now = DateTimeOffset.UtcNow;
+ messages = messages.Where(m => !_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
+ .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 cardChannel = cachedChannel != null ? CardChannel.From(cachedChannel) : new CardChannel(channel.Id, null);
+ await _loggingService.LogComponentsToChannel(
+ AuditLogCards.BulkDeletedCard(cardChannel, listed, uncachedCount, DateTimeOffset.UtcNow));
+ }
+
+ 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 not SocketGuild socketGuild || socketGuild.GetUser(author.Id) != null;
+ 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)
{
- 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;
- if (content == after.Content)
+ var decision = MessageEditClassifier.Classify(beforeVersion, afterVersion, now, lastLogged);
+ if (decision.Kind == EditKind.Ignore)
return;
- if (content.Length == 0 && (beforeMessage?.Attachments.Count ?? 0) == 0)
+ if (decision.Kind == EditKind.EditedUncached)
+ RememberUncachedEdit(after.Id, afterVersion.EditedAt!.Value, now);
+
+ 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;
+ 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/IntroductionPromptSchedule.cs b/DiscordBot/Services/Server/IntroductionPromptSchedule.cs
new file mode 100644
index 00000000..6e05f5c4
--- /dev/null
+++ b/DiscordBot/Services/Server/IntroductionPromptSchedule.cs
@@ -0,0 +1,61 @@
+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;
+ }
+ }
+
+ ///
+ /// 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
new file mode 100644
index 00000000..b439f62a
--- /dev/null
+++ b/DiscordBot/Services/Server/IntroductionWatcherService.cs
@@ -0,0 +1,210 @@
+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 TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
+ 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!;
+ 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;
+ _settings = settings;
+ _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(ChannelInfo? rulesChannel, ChannelInfo? helpChannel) =>
+ new ComponentBuilderV2()
+ .WithContainer(new ContainerBuilder()
+ .WithAccentColor(AccentColour)
+ .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("**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))
+ _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)
+ {
+ // 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)
+ {
+ // 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);
+ }
+ }
+
+ ///
+ /// 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 LogWarningAsync($"Could not read Introductions history on startup: {e.Message}");
+ }
+ }
+
+ 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(_settings.Channels.Rules, _settings.Channels.GenericHelp),
+ 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 LogWarningAsync($"Failed to delete old introduction prompt {messageId}: {e.Message}");
+ }
+ }
+}
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/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs
index 6e9ea102..9e46b052 100644
--- a/DiscordBot/Settings/Deserialized/Settings.cs
+++ b/DiscordBot/Settings/Deserialized/Settings.cs
@@ -20,8 +20,10 @@ 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();
+ public ImageSpamGuardSettings ImageSpamGuard { get; set; } = new();
public (List Errors, List Warnings) Validate()
{
@@ -51,6 +53,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));
@@ -74,6 +79,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 โ 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)
+ warnings.Add($"ImageSpamGuard.TimeoutHours must be between 1 and {ImageSpamGuardSettings.MaxTimeoutHours} (Discord's 28-day limit)");
+ }
+
return (errors, warnings);
}
@@ -147,6 +162,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;
@@ -162,6 +183,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..9e7b8ac9 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": "",
@@ -73,5 +78,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 97885561..1ccc41cb 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 |
@@ -29,7 +29,9 @@
| **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 |
+| **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 |
diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml
index 1c9f60e7..d3f133ab 100644
--- a/k8s/dev/bot-config.yaml
+++ b/k8s/dev/bot-config.yaml
@@ -66,6 +66,11 @@ data:
"TipImageDirectory": "tips"
},
+ "IntroductionWatch": {
+ "Enabled": true,
+ "DelayMinutes": 2
+ },
+
"FunCommands": {
"SlapObjectsTable": "Settings/udc-slap.txt",
"SlapChoices": [
@@ -81,5 +86,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..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": [
@@ -88,5 +93,12 @@ data:
"Birthday": {
"Enabled": true,
"CheckIntervalMinutes": 60
+ },
+
+ "ImageSpamGuard": {
+ "Enabled": true,
+ "ImageCount": 4,
+ "WindowMinutes": 15,
+ "TimeoutHours": 24
}
}