diff --git a/src/VahterBanBot/Bot.fs b/src/VahterBanBot/Bot.fs index f77e4b3..f39d531 100644 --- a/src/VahterBanBot/Bot.fs +++ b/src/VahterBanBot/Bot.fs @@ -683,8 +683,21 @@ type BotService( logger.LogInformation logMsg // 4. Karma check + autoban - let! _ = this.CheckAndAutoBan(msg, actor) - () + let! justBanned = this.CheckAndAutoBan(msg, actor) + + // 5. Ephemeral heads-up to the sender (Bot API 10.2), visible only to them in the same + // chat. Skipped if they were just total-banned above (a ban notice, not a warning, is + // the right signal there) and restricted to the near-zero-false-positive text/LLM + // verdicts — InvisibleMention/SpamTextCacheHit/ReactionSpam are deliberately excluded so + // a warning never teaches a spammer which signature tripped detection. Best-effort: + // delivery is not guaranteed and a failure must never fail the deletion, hence + // CallIgnore (never CallExn) — see AdminCommand's `confirm` helper for the same pattern. + if botConfig.Value.SpamWarningEnabled && not justBanned then + match reason with + | AutoDeleteReason.MlSpam _ | AutoDeleteReason.LlmSpam _ | AutoDeleteReason.ContentFilterSpam _ -> + do! tg.CallIgnore(Req.SendMessage.Make(msg.ChatId, botConfig.Value.SpamWarningText, receiverUserId = msg.SenderId)) + recordSpamWarningSent msg.ChatId msg.ChatUsername + | AutoDeleteReason.ReactionSpam _ | AutoDeleteReason.InvisibleMention | AutoDeleteReason.SpamTextCacheHit _ -> () } /// Reports uncertain spam to potential spam channel with KILL/SPAM/NOT SPAM buttons for human triage. diff --git a/src/VahterBanBot/Metrics.fs b/src/VahterBanBot/Metrics.fs index 17726a2..b7797ed 100644 --- a/src/VahterBanBot/Metrics.fs +++ b/src/VahterBanBot/Metrics.fs @@ -30,6 +30,13 @@ let bannedUsersCounter = "Total number of users banned by vahters" ) +let spamWarningsSentCounter = + meter.CreateCounter( + "vahter_spam_warnings_sent_total", + "warnings", + "Total number of ephemeral spam warnings sent to users after auto-deletion" + ) + let spamTextCacheSeedsCounter = meter.CreateCounter( "vahter_spam_text_cache_seeds_total", @@ -88,4 +95,7 @@ let recordDeletedMessagesBatch (chatId: int64) (chatUsername: string) (count: in if count > 0 then deletedMessagesCounter.Add(int64 count, tagsForDeletedMessage chatId chatUsername reason) +let recordSpamWarningSent (chatId: int64) (chatUsername: string) = + spamWarningsSentCounter.Add(1L, tagsForChat chatId chatUsername) + diff --git a/src/VahterBanBot/Program.fs b/src/VahterBanBot/Program.fs index 5dd1d79..908a6ab 100644 --- a/src/VahterBanBot/Program.fs +++ b/src/VahterBanBot/Program.fs @@ -155,7 +155,17 @@ let buildBotConf () = // see AGENTS.md's Settings configuration section and SpamTextCache.fs. SpamTextCacheMode = getSettingOr "SPAM_TEXT_CACHE_MODE" "off" |> SpamTextCacheMode.FromString SpamTextCacheTtl = getSettingOr "SPAM_TEXT_CACHE_TTL_HOURS" "24" |> float |> TimeSpan.FromHours - SpamTextCacheMinLength = getSettingOr "SPAM_TEXT_CACHE_MIN_LENGTH" "40" |> int } + SpamTextCacheMinLength = getSettingOr "SPAM_TEXT_CACHE_MIN_LENGTH" "40" |> int + // Ephemeral spam-deletion warning. Off by default — see BotConfiguration's doc comment. + SpamWarningEnabled = getSettingOr "SPAM_WARNING_ENABLED" "false" |> bool.Parse + SpamWarningText = + getSettingOr "SPAM_WARNING_TEXT" + ("⚠️ Ваше сообщение было автоматически удалено, потому что оно похоже на спам. " + + "Пожалуйста, не отправляйте его повторно — повторные удаления могут привести к бану. " + + "Модераторы видят все удаления и разберутся, если это ошибка.\n\n" + + "⚠️ Your message was removed automatically because it looks like spam. Please do not " + + "post it again — repeated removals may lead to a ban. Moderators can see all removals " + + "and will sort it out if this was a mistake.") } let ocrConfigOf (c: BotConfiguration) = { OcrEnabled = c.OcrEnabled diff --git a/src/VahterBanBot/Types.fs b/src/VahterBanBot/Types.fs index d57350a..4141f20 100644 --- a/src/VahterBanBot/Types.fs +++ b/src/VahterBanBot/Types.fs @@ -510,7 +510,15 @@ type BotConfiguration = // require a redeploy — see AGENTS.md's Settings configuration section. SpamTextCacheMode: SpamTextCacheMode SpamTextCacheTtl: TimeSpan - SpamTextCacheMinLength: int } + SpamTextCacheMinLength: int + // Ephemeral warning to the user whose message was just auto-deleted as spam (Bot API + // 10.2). Off by default; only sent for MlSpam/LlmSpam/ContentFilterSpam deletions of a + // user who was NOT just auto-banned by CheckAndAutoBan — see Bot.fs's DeleteSpam. + SpamWarningEnabled: bool + /// Fixed bilingual warning text, bot_setting-backed so it's tunable via POST + /// /reload-settings without a redeploy. Deliberately generic — no scores, no ML/LLM + /// distinction, no strike counts — so it doesn't teach spammers how detection works. + SpamWarningText: string } member this.BotActor = Actor.Bot (Some {| botUserId = this.BotUserId; botUsername = this.BotUserName |}) diff --git a/tests/VahterBanBot.Tests/SpamWarningTests.fs b/tests/VahterBanBot.Tests/SpamWarningTests.fs new file mode 100644 index 0000000..bd06c45 --- /dev/null +++ b/tests/VahterBanBot.Tests/SpamWarningTests.fs @@ -0,0 +1,181 @@ +module VahterBanBot.Tests.SpamWarningTests + +open System +open System.Threading.Tasks +open VahterBanBot.Tests.ContainerTestBase +open BotTestInfra +open Xunit + +/// Ephemeral warning sent to a user whose message was just auto-deleted as spam (Bot API 10.2, +/// DeleteSpam). SPAM_WARNING_ENABLED defaults to false; tests that need it flip the bot_setting +/// and DisposeAsync restores it after every test — same pattern as EphemeralTests/ReportCommandTests. +type SpamWarningMlTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture) = + + let setSpamWarning (enabled: bool) = task { + do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", if enabled then "true" else "false") + do! fixture.ReloadSettings() + } + + [] + let ``Flag ON: ML spam deletion of a non-banned user sends exactly one ephemeral warning`` () = task { + do! setSpamWarning true + + let user = Tg.user() + do! fixture.ClearFakeCalls() + // "2222222" is a training-set spam word (see test_seed.sql); a single message never + // crosses the karma-autoban threshold on its own. + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = user) + let! _ = fixture.SendMessage msgUpdate + + let! msgDeleted = fixture.MessageIsAutoDeleted msgUpdate.Message.Value + Assert.True(msgDeleted, "Sanity: message should have been auto-deleted as spam") + let! userBanned = fixture.UserBannedByBot user.Id + Assert.False(userBanned, "Sanity: a single spam message must not trigger karma autoban") + + let! calls = fixture.GetFakeCalls "sendMessage" + let warnings = + calls + |> Array.filter (fun c -> + c.Body.Contains $"\"chat_id\":{fixture.ChatsToMonitor[0].Id}" + && c.Body.Contains $"\"receiver_user_id\":{user.Id}") + Assert.Equal(1, warnings.Length) + Assert.Contains("automatically", warnings[0].Body) + } + + [] + let ``Flag OFF (default): no warning is sent for the same scenario`` () = task { + do! setSpamWarning false + + let user = Tg.user() + do! fixture.ClearFakeCalls() + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = user) + let! _ = fixture.SendMessage msgUpdate + + let! msgDeleted = fixture.MessageIsAutoDeleted msgUpdate.Message.Value + Assert.True(msgDeleted, "Sanity: message should still be auto-deleted with the flag off") + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.False( + calls |> Array.exists (fun c -> c.Body.Contains $"\"receiver_user_id\":{user.Id}"), + "no ephemeral warning must be sent while SPAM_WARNING_ENABLED is false") + } + + [] + let ``Flag ON: the deletion that triggers karma autoban sends no warning`` () = task { + do! setSpamWarning true + + // Same "66666666" / 4-consecutive-spam karma-autoban shape as MLBanTests. + let user = Tg.user() + let spam = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "66666666", from = user) + for _ in 1..3 do + let! _ = fixture.SendMessage spam + () + let! bannedYet = fixture.UserBannedByBot user.Id + Assert.False(bannedYet, "Sanity: user should not be auto-banned after only 3 spam messages") + + do! fixture.ClearFakeCalls() + let! _ = fixture.SendMessage spam + let! banned = fixture.UserBannedByBot user.Id + Assert.True(banned, "Sanity: 4th spam message should trigger karma autoban") + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.False( + calls |> Array.exists (fun c -> c.Body.Contains $"\"receiver_user_id\":{user.Id}"), + "a deletion that triggers total-ban must not also send an ephemeral warning") + } + + // Restore the flag to its default after every test. + interface IAsyncDisposable with + member _.DisposeAsync() = + ValueTask(task { + do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", "false") + do! fixture.ReloadSettings() + } :> Task) + + interface IClassFixture + +/// SpamTextCacheHit is one of the excluded reasons (near-zero-false-positive spammer +/// signatures) — a warning there would only educate spammers. +type SpamWarningCacheHitTests(fixture: SpamTextCacheEnforceTestContainers, _unused: MlAwaitFixture) = + + let setSpamWarning (enabled: bool) = task { + do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", if enabled then "true" else "false") + do! fixture.ReloadSettings() + } + + [] + let ``Flag ON: a ban-seeded spam-text cache hit deletion sends no warning`` () = task { + do! setSpamWarning true + + let spamText = $"click this link right now to claim your huge prize before it expires forever {System.Guid.NewGuid()}" + let vahter = fixture.Vahters[0] + let originalMsg = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = spamText) + let! _ = fixture.SendMessage originalMsg + let! _ = Tg.replyMsg(originalMsg.Message.Value, "/ban", vahter) |> fixture.SendMessage + let! seedUserBanned = fixture.UserBanned originalMsg.Message.Value.From.Value.Id + Assert.True(seedUserBanned, "Sanity: original spammer should be banned, seeding the cache") + + let repeatUser = Tg.user() + do! fixture.ClearFakeCalls() + let repeatMsg = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = spamText, from = repeatUser) + let! _ = fixture.SendMessage repeatMsg + + let! wasDeleted = fixture.MessageIsAutoDeleted repeatMsg.Message.Value + Assert.True(wasDeleted, "Sanity: repeat of a just-banned message should be deleted (enforce mode)") + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.False( + calls |> Array.exists (fun c -> c.Body.Contains $"\"receiver_user_id\":{repeatUser.Id}"), + "SpamTextCacheHit deletions must never send the ephemeral warning") + } + + interface IAsyncDisposable with + member _.DisposeAsync() = + ValueTask(task { + do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", "false") + do! fixture.ReloadSettings() + } :> Task) + + interface IClassFixture + +/// The "already banned" fast-delete path (JustMessage) has its own deletion code and never +/// calls DeleteSpam at all — this locks that finding in as a regression test. +type SpamWarningAlreadyBannedTests(fixture: MlDisabledVahterTestContainers) = + + let setSpamWarning (enabled: bool) = task { + do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", if enabled then "true" else "false") + do! fixture.ReloadSettings() + } + + [] + let ``Flag ON: a late message from an already-banned user sends no warning`` () = task { + do! setSpamWarning true + + let spammer = Tg.user() + let firstMsg = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], from = spammer) + let! _ = fixture.SendMessage firstMsg + let! _ = Tg.replyMsg(firstMsg.Message.Value, "/ban", fixture.Vahters[0]) |> fixture.SendMessage + let! banned = fixture.MessageBanned firstMsg.Message.Value + Assert.True(banned, "Sanity: spammer should be banned by the manual /ban") + + do! fixture.ClearFakeCalls() + let secondMsg = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], from = spammer) + let! _ = fixture.SendMessage secondMsg + + let! deleteCalls = fixture.GetFakeCalls "deleteMessage" + Assert.True( + deleteCalls |> Array.exists (fun c -> c.Body.Contains $"\"message_id\":{secondMsg.Message.Value.MessageId}"), + "Sanity: the late message from an already-banned user should still be deleted") + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.False( + calls |> Array.exists (fun c -> c.Body.Contains $"\"receiver_user_id\":{spammer.Id}"), + "the already-banned fast-delete path (JustMessage) never goes through DeleteSpam and must never warn") + } + + interface IAsyncDisposable with + member _.DisposeAsync() = + ValueTask(task { + do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", "false") + do! fixture.ReloadSettings() + } :> Task) diff --git a/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj b/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj index 3800722..0f8318f 100644 --- a/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj +++ b/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj @@ -38,6 +38,7 @@ +