diff --git a/src/VahterBanBot/Bot.fs b/src/VahterBanBot/Bot.fs index f39d531..ffe3d8f 100644 --- a/src/VahterBanBot/Bot.fs +++ b/src/VahterBanBot/Bot.fs @@ -689,15 +689,23 @@ type BotService( // 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. + // a warning never teaches a spammer which signature tripped detection. Further capped by + // SpamWarningMaxScore (strict <) so blatant spam (high score) is deleted silently — see + // BotConfiguration's doc comment. 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 _ -> + let warnedReasonScore = + match reason with + | AutoDeleteReason.MlSpam x -> Some x.score + | AutoDeleteReason.LlmSpam x -> Some x.score + | AutoDeleteReason.ContentFilterSpam x -> Some x.score + | AutoDeleteReason.ReactionSpam _ | AutoDeleteReason.InvisibleMention | AutoDeleteReason.SpamTextCacheHit _ -> None + match warnedReasonScore with + | Some score when score < botConfig.Value.SpamWarningMaxScore -> 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 _ -> () + | Some _ | None -> () } /// Reports uncertain spam to potential spam channel with KILL/SPAM/NOT SPAM buttons for human triage. diff --git a/src/VahterBanBot/Program.fs b/src/VahterBanBot/Program.fs index 908a6ab..edd5f01 100644 --- a/src/VahterBanBot/Program.fs +++ b/src/VahterBanBot/Program.fs @@ -165,7 +165,8 @@ let buildBotConf () = + "Модераторы видят все удаления и разберутся, если это ошибка.\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.") } + + "and will sort it out if this was a mistake.") + SpamWarningMaxScore = getSettingOr "SPAM_WARNING_MAX_SCORE" "3.0" |> double } let ocrConfigOf (c: BotConfiguration) = { OcrEnabled = c.OcrEnabled diff --git a/src/VahterBanBot/Types.fs b/src/VahterBanBot/Types.fs index 4141f20..2181565 100644 --- a/src/VahterBanBot/Types.fs +++ b/src/VahterBanBot/Types.fs @@ -518,7 +518,13 @@ type BotConfiguration = /// 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 } + SpamWarningText: string + /// Only warn when the deletion's ML score is below this cutoff (strict <), i.e. the + /// likely-false-positive band — blatant spam (high score) is deleted silently, no warning. + /// bot_setting-backed (not a constant) because the score scale drifts under daily ML + /// retraining. Default 3.0 is prod-data-derived (93.8% ham-deletion coverage, 43-point + /// spammer-warning reduction) — see Bot.fs's DeleteSpam. + SpamWarningMaxScore: float } 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 index bd06c45..1c13e2c 100644 --- a/tests/VahterBanBot.Tests/SpamWarningTests.fs +++ b/tests/VahterBanBot.Tests/SpamWarningTests.fs @@ -22,8 +22,10 @@ type SpamWarningMlTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwait 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. + // "2222222" is a training-set spam word (see test_seed.sql), scoring 1.5686... under the + // fixture ML model (see MLScoreDeterminismTests) — comfortably below the default + // SPAM_WARNING_MAX_SCORE of 3.0. 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 @@ -42,6 +44,72 @@ type SpamWarningMlTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwait Assert.Contains("automatically", warnings[0].Body) } + [] + let ``Flag ON: ML spam deletion scoring at/above SPAM_WARNING_MAX_SCORE sends no warning`` () = task { + do! setSpamWarning true + + // Probe the exact ML score for this text under a deliberately permissive cutoff, then + // set SPAM_WARNING_MAX_SCORE just below it — robust against the underlying ML model + // (and its score scale) changing under retraining, unlike hardcoding a score value. + do! fixture.SetBotSetting("SPAM_WARNING_MAX_SCORE", "999") + do! fixture.ReloadSettings() + let probeMsg = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = Tg.user()) + let! _ = fixture.SendMessage probeMsg + let! scoreOpt = fixture.GetMlScore probeMsg.Message.Value + let score = + match scoreOpt with + | Some s -> s + | None -> failwith "Sanity: probe message should have been ML-scored" + + do! fixture.SetBotSetting("SPAM_WARNING_MAX_SCORE", string (score - 0.5)) + do! fixture.ReloadSettings() + 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 regardless of the warning cutoff") + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.False( + calls |> Array.exists (fun c -> c.Body.Contains $"\"receiver_user_id\":{user.Id}"), + "a deletion scoring at/above SPAM_WARNING_MAX_SCORE must not send the ephemeral warning") + } + + [] + let ``Flag ON: boundary — score exactly equal to SPAM_WARNING_MAX_SCORE sends no warning`` () = task { + do! setSpamWarning true + + // Same probe-then-pin approach as the at/above-cutoff test, but this time the cutoff is + // pinned to the *exact* observed score (round-trippable via .NET's default double + // ToString()), exercising the strict `<` boundary rather than a margin below it. + do! fixture.SetBotSetting("SPAM_WARNING_MAX_SCORE", "999") + do! fixture.ReloadSettings() + let probeMsg = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = Tg.user()) + let! _ = fixture.SendMessage probeMsg + let! scoreOpt = fixture.GetMlScore probeMsg.Message.Value + let score = + match scoreOpt with + | Some s -> s + | None -> failwith "Sanity: probe message should have been ML-scored" + + do! fixture.SetBotSetting("SPAM_WARNING_MAX_SCORE", string score) + do! fixture.ReloadSettings() + 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 at the boundary") + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.False( + calls |> Array.exists (fun c -> c.Body.Contains $"\"receiver_user_id\":{user.Id}"), + "score == SPAM_WARNING_MAX_SCORE must not warn -- the cutoff is a strict less-than") + } + [] let ``Flag OFF (default): no warning is sent for the same scenario`` () = task { do! setSpamWarning false @@ -84,11 +152,12 @@ type SpamWarningMlTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwait "a deletion that triggers total-ban must not also send an ephemeral warning") } - // Restore the flag to its default after every test. + // Restore the flag and cutoff to their defaults after every test. interface IAsyncDisposable with member _.DisposeAsync() = ValueTask(task { do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", "false") + do! fixture.SetBotSetting("SPAM_WARNING_MAX_SCORE", "3.0") do! fixture.ReloadSettings() } :> Task)