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
17 changes: 15 additions & 2 deletions src/VahterBanBot/Bot.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/VahterBanBot/Metrics.fs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ let bannedUsersCounter =
"Total number of users banned by vahters"
)

let spamWarningsSentCounter =
meter.CreateCounter<int64>(
"vahter_spam_warnings_sent_total",
"warnings",
"Total number of ephemeral spam warnings sent to users after auto-deletion"
)

let spamTextCacheSeedsCounter =
meter.CreateCounter<int64>(
"vahter_spam_text_cache_seeds_total",
Expand Down Expand Up @@ -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)


12 changes: 11 additions & 1 deletion src/VahterBanBot/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/VahterBanBot/Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |})

Expand Down
181 changes: 181 additions & 0 deletions tests/VahterBanBot.Tests/SpamWarningTests.fs
Original file line number Diff line number Diff line change
@@ -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()
}

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

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

[<Fact>]
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<MlAwaitFixture>

/// 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()
}

[<Fact>]
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<MlAwaitFixture>

/// 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()
}

[<Fact>]
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)
1 change: 1 addition & 0 deletions tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<Compile Include="AdminChannelMessageTests.fs" />
<Compile Include="SnapshotTests.fs" />
<Compile Include="SpamTextCacheTests.fs" />
<Compile Include="SpamWarningTests.fs" />
<Compile Include="Program.fs"/>
</ItemGroup>

Expand Down
Loading