Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src/VahterBanBot/Bot.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/VahterBanBot/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/VahterBanBot/Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |})

Expand Down
75 changes: 72 additions & 3 deletions tests/VahterBanBot.Tests/SpamWarningTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -42,6 +44,72 @@ type SpamWarningMlTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwait
Assert.Contains("automatically", warnings[0].Body)
}

[<Fact>]
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")
}

[<Fact>]
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")
}

[<Fact>]
let ``Flag OFF (default): no warning is sent for the same scenario`` () = task {
do! setSpamWarning false
Expand Down Expand Up @@ -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)

Expand Down
Loading