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 } }