From 6371c0395bfe63ae8284ce79698ef8aa6576655a Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Wed, 19 Aug 2026 11:58:00 +0100 Subject: [PATCH 1/2] VahterBanBot: temporary vetted-user protection after ham mark (demote auto-delete to report-only, flag off) After a vahter (or /vahter unmarkspam) reverses an auto-deletion as a false positive, the author gets a time-limited protection window during which would-be ML/LLM/content-filter auto-deletions are demoted to the existing report-only path (Potential Spam card) instead of deleted, tagged "protected user" for moderators. Never a full ML skip: vahters still see every message, a demotion budget caps abuse, and one KILL click bans as usual. Off by default (SPAM_PROTECTION_ENABLED=false). Claude-Session: https://claude.ai/code/session_01Wi7gKmshkHfVtSB3tA4gmJ Co-authored-by: Claude Fable 5 --- src/VahterBanBot/Bot.fs | 133 +++++- src/VahterBanBot/DB.fs | 45 ++ src/VahterBanBot/Metrics.fs | 37 ++ src/VahterBanBot/Program.fs | 14 +- src/VahterBanBot/Types.fs | 66 ++- .../V43__spam_protection_snapshot.sql | 14 + tests/VahterBanBot.Tests/ContainerTestBase.fs | 58 ++- .../VahterBanBot.Tests/SpamProtectionTests.fs | 407 ++++++++++++++++++ .../VahterBanBot.Tests.fsproj | 1 + 9 files changed, 752 insertions(+), 23 deletions(-) create mode 100644 src/vahter-bot/migrations/V43__spam_protection_snapshot.sql create mode 100644 tests/VahterBanBot.Tests/SpamProtectionTests.fs diff --git a/src/VahterBanBot/Bot.fs b/src/VahterBanBot/Bot.fs index f39d531..4fe14ef 100644 --- a/src/VahterBanBot/Bot.fs +++ b/src/VahterBanBot/Bot.fs @@ -405,6 +405,15 @@ type BotService( .SetTag("promptHash", l.promptHash) | _ -> () + // Spam protection: any total ban makes an active window moot (a banned user skips + // processing entirely), but revoke here for a clean ledger — this is the single central + // hook for every TotalBan caller (manual /ban, BanOnReply/KILL, ML/LLM/Bot autobans via + // CheckAndAutoBan) rather than duplicating the call at each one. No-op (and metric-silent) + // if the user has no active grant, or if a more specific call site (e.g. the KILL button) + // already revoked it with a more precise reason. + let! spamProtectionRevoked = db.RecordSpamProtectionRevoked(msg.SenderId, "banned") + if spamProtectionRevoked then recordSpamProtectionRevoked msg.ChatId msg.ChatUsername "banned" + // Ban-seeded spam-text cache: seed ONLY on a genuinely manual ban (Actor.User — i.e. // this is the /ban and BanOnReply path). This is a deliberate exhaustive-by-actor-shape // decision, not a catch-all — ML/LLM/Bot auto-bans (which also flow through TotalBan, @@ -573,6 +582,11 @@ type BotService( let logText = softBanResultInLogMsg messageToRemove vahter duration (utcNow()) + // /sban doesn't flow through TotalBan (it mutes rather than bans), so it needs its own + // revoke call — same "banned" bucket as TotalBan's central hook. + let! spamProtectionRevoked = db.RecordSpamProtectionRevoked(messageToRemove.SenderId, "banned") + if spamProtectionRevoked then recordSpamProtectionRevoked messageToRemove.ChatId messageToRemove.ChatUsername "banned" + do! this.SoftBanInChat(messageToRemove.ChatId, messageToRemove.SenderId, duration) |> taskIgnore do! deleteMsgTask @@ -701,16 +715,22 @@ type BotService( } /// Reports uncertain spam to potential spam channel with KILL/SPAM/NOT SPAM buttons for human triage. - /// Does NOT delete the message. - member private _.ReportPotentialSpam(msg: TgMessage, reason: AutoDeleteReason) = task { + /// Does NOT delete the message. `protectedUser` (default false) is set by EnforceOrDemote when + /// this call is a demotion of a would-be deletion for a protected user — it prefixes the card + /// with a moderator-only "🛡 protected user" tag. The tag/text must never say WHY (time-boxed, + /// budgeted) so a wrongly-vetted spammer reading a forwarded card can't learn a shield exists. + member private _.ReportPotentialSpam(msg: TgMessage, reason: AutoDeleteReason, ?protectedUser: bool) = task { + let isProtectedDemotion = defaultArg protectedUser false use activity = botActivity.StartActivity("reportPotentialSpam") %activity .SetTag("spammerId", msg.SenderId) .SetTag("spammerUsername", msg.SenderUsername) + .SetTag("protectedDemotion", isProtectedDemotion) // Button post carries no #ref (identity travels in the callback payloads); // the AllLogs mirror gets the token so it can be forward-actioned via /vahter markspam. - let baseMsg = $"Detected spam ({formatReasonStr reason None}) in {prependUsername msg.ChatUsername} ({msg.ChatId}) from {prependUsername msg.SenderUsername} ({msg.SenderId}) with text:\n{msg.Text}" + let protectedTag = if isProtectedDemotion then "🛡 protected user\n" else "" + let baseMsg = $"{protectedTag}Detected spam ({formatReasonStr reason None}) in {prependUsername msg.ChatUsername} ({msg.ChatId}) from {prependUsername msg.SenderUsername} ({msg.SenderId}) with text:\n{msg.Text}" let logMsg = $"{baseMsg}\n{msgRefToken msg.ChatId msg.MessageId}" // Create three callbacks for human triage @@ -738,6 +758,35 @@ type BotService( logger.LogInformation logMsg } + /// Grants (or refreshes) the temporary spam-protection window for the author of a ham-marked + /// message, but ONLY if that message was actually auto-deleted (BotAutoDeleted on its + /// moderation stream) — a ham mark on a message that was merely reported (never deleted) has + /// nothing to protect against. Called from both ham-mark entry points: the "✅ NOT a spam" + /// button (VahterMarkedAsNotSpam) and `/vahter unmarkspam` (VahterUnmarkSpam) — the latter + /// only has (chatId, messageId) from the #ref token, so the author's userId is recovered from + /// the BotAutoDeleted event itself (TryGetBotAutoDeletedUserId), same as the design note. + /// `chatUsername` is best-effort (metrics tagging only) — null when the caller doesn't have it. + member private this.MaybeGrantSpamProtection(chatId: int64, chatUsername: string, messageId: int64, vahterId: int64) = task { + if botConfig.Value.SpamProtectionEnabled then + let! autoDeletedUserId = db.TryGetBotAutoDeletedUserId(chatId, messageId) + match autoDeletedUserId with + | None -> () // never auto-deleted — nothing to protect + | Some userId -> + let until = (utcNow()).AddHours(float botConfig.Value.SpamProtectionHours) + do! db.RecordSpamProtectionGranted(userId, until, chatId, messageId, vahterId) + recordSpamProtectionGranted chatId chatUsername + logger.LogInformation( + "Granted spam protection to user {UserId} until {Until:u} (ham mark on {ChatId}/{MessageId} by vahter {VahterId})", + userId, until, chatId, messageId, vahterId) + + // Best-effort ephemeral heads-up to the vetted user, same CallIgnore pattern as + // SpamWarningEnabled — a delivery failure must never fail the ham mark. Text is + // deliberately generic (see SpamProtectionNotifyText's doc comment): it must never + // reveal that enforcement was relaxed or time-boxed. + if botConfig.Value.SpamProtectionNotifyEnabled then + do! tg.CallIgnore(Req.SendMessage.Make(chatId, botConfig.Value.SpamProtectionNotifyText, receiverUserId = userId)) + } + // ── Reaction-spam triage pipeline ────────────────────────────────────── /// Iterates every reaction this user has placed (optionally limited to one chat) and @@ -1215,7 +1264,44 @@ type BotService( () // unreachable — ProcessMessage never computes a hit in Off mode } - member private this.ProcessMessage(msg: TgMessage) = task { + /// Plain (non-demotable) enforcement — DeleteSpam when ML_SPAM_DELETION_ENABLED, else just + /// a ReportPotentialSpam card. Exactly the pre-existing `enforceSpam`/pre-OCR behavior, + /// factored out so EnforceOrDemote below can fall back to it unchanged. + member private this.EnforceSpamPlain(msg: TgMessage, actor: Actor, reason: AutoDeleteReason) = task { + if botConfig.Value.MlSpamDeletionEnabled then + do! this.DeleteSpam(msg, actor, reason) + else + do! this.ReportPotentialSpam(msg, reason) + } + + /// Wraps a would-be MlSpam/LlmSpam/ContentFilterSpam enforcement with the temporary + /// post-ham-mark protection check (SPAM_PROTECTION_*, locked design). NEVER used for the + /// SpamTextCacheHit/InvisibleMention/ReactionSpam carve-outs — those call EnforceSpamPlain / + /// DeleteSpam / ReportPotentialSpam directly and are untouched by this feature. + /// `user` is whatever JustMessage already fetched via GetUserById for the banned-check — + /// reused here instead of a second per-message query. + member private this.EnforceOrDemote(msg: TgMessage, actor: Actor, reason: AutoDeleteReason, user: User option) = task { + if not botConfig.Value.SpamProtectionEnabled then + do! this.EnforceSpamPlain(msg, actor, reason) + else + let now = utcNow() + match user with + | Some u when u.SpamProtectionActive(botConfig.Value.SpamProtectionMaxHits, botConfig.Value.BanExpiryDays, now) -> + // Demote: report instead of delete, tag the card, and consume one hit of budget. + do! this.ReportPotentialSpam(msg, reason, protectedUser = true) + do! db.RecordSpamProtectionConsumed(msg.SenderId, msg.ChatId, msg.MessageId) + recordSpamProtectionDemotion msg.ChatId msg.ChatUsername + | Some u when u.HasUnexpiredSpamProtectionGrant now -> + // Grant present but the demotion budget is exhausted on THIS message — revoke + // and let it delete normally, same as any other would-be spam deletion. + let! revoked = db.RecordSpamProtectionRevoked(u.Id, "budget") + if revoked then recordSpamProtectionRevoked msg.ChatId msg.ChatUsername "budget" + do! this.EnforceSpamPlain(msg, actor, reason) + | _ -> + do! this.EnforceSpamPlain(msg, actor, reason) + } + + member private this.ProcessMessage(msg: TgMessage, user: User option) = task { // Records the message exactly once, with whatever enrichment finished // by the time we call it. Each branch below calls this at the right // point so the persisted text matches the text we classified on, and @@ -1356,10 +1442,7 @@ type BotService( msg.MessageId, score) do! recordMsg() let reason = MlSpam {| score = score |} - if botConfig.Value.MlSpamDeletionEnabled then - do! this.DeleteSpam(msg, Actor.ML, reason) - else - do! this.ReportPotentialSpam(msg, reason) + do! this.EnforceOrDemote(msg, Actor.ML, reason, user) | None -> %mlActivity.SetTag("preOcrShortCircuit", false) // Text alone wasn't enough — pay for Azure OCR on the @@ -1375,13 +1458,10 @@ type BotService( do! recordMsg() let! autoVerdict = this.GetAutoVerdict(msg, usrMsgCount) // Shared by AutoVerdict.Spam and AutoVerdict.ContentFilterSpam below — both - // take the EXACT SAME delete/report enforcement gating; only the `reason` - // (and hence formatReasonStr's rendering) differs between the two arms. + // take the EXACT SAME delete/report/demote enforcement gating; only the + // `reason` (and hence formatReasonStr's rendering) differs between the two arms. let enforceSpam (actor: Actor) (reason: AutoDeleteReason) = task { - if botConfig.Value.MlSpamDeletionEnabled then - do! this.DeleteSpam(msg, actor, reason) - else - do! this.ReportPotentialSpam(msg, reason) + do! this.EnforceOrDemote(msg, actor, reason, user) } match autoVerdict with | Some (AutoVerdict.Spam (score, actor)) -> @@ -1434,7 +1514,7 @@ type BotService( do! tg.CallExn(Req.DeleteMessage.Make(msg.ChatId, msg.MessageId)) |> safeTaskAwait (fun e -> logger.LogDebug(e, "Failed to delete message {MessageId} from chat {ChatId}", msg.MessageId, msg.ChatId)) - else do! this.ProcessMessage(msg) + else do! this.ProcessMessage(msg, user) } // ----------------------------------------------------------------------- @@ -1693,6 +1773,7 @@ type BotService( do! this.ReplyAdmin(msg, "Could not find a message reference in that post. Forward a bot log message from the logs channel and reply /vahter unmarkspam to it.") | Some(chatId, messageId) -> do! db.RecordMessageMarkedHam(chatId, messageId, "", Some vahter.Id) + do! this.MaybeGrantSpamProtection(chatId, null, messageId, vahter.Id) do! this.ReplyAdmin(msg, $"✅ Reversed: message {messageId} in chat {chatId} marked as NOT spam (ham).") logger.LogInformation($"Vahter {vahter.Id} reversed spam mark for {chatId}:{messageId}") } @@ -1710,6 +1791,16 @@ type BotService( do! this.ReplyAdmin(msg, "Could not find a message reference in that post. Forward a bot log message from the logs channel and reply /vahter markspam to it.") | Some(chatId, messageId) -> do! db.RecordMessageMarkedSpam(chatId, messageId, Some vahter.Id) + // Classification-only reversal of a possible earlier grant — no TgMessage/author + // id at hand here, but RecordMessageMarkedSpam's target is the same message the + // ham mark (if any) would have granted on, so recover the author the same way + // MaybeGrantSpamProtection does. + let! autoDeletedUserId = db.TryGetBotAutoDeletedUserId(chatId, messageId) + match autoDeletedUserId with + | Some userId -> + let! revoked = db.RecordSpamProtectionRevoked(userId, "markspam") + if revoked then recordSpamProtectionRevoked chatId null "markspam" + | None -> () do! this.ReplyAdmin(msg, $"✅ Message {messageId} in chat {chatId} marked as spam.") logger.LogInformation($"Vahter {vahter.Id} marked {chatId}:{messageId} as spam") } @@ -2178,6 +2269,7 @@ type BotService( .SetTag("messageId", msgId) .SetTag("chatId", chatId) do! db.RecordMessageMarkedHam(chatId, msgId, (if isNull tgMsg.Text then "" else tgMsg.Text), Some vahter.Id) + do! this.MaybeGrantSpamProtection(chatId, chatName, msgId, vahter.Id) let vahterUsername = vahter.Username |> Option.defaultValue null @@ -2197,6 +2289,11 @@ type BotService( let isAuthed = isBanAuthorized botConfig.Value tgMsg vahter logger if isAuthed then + // The KILL verdict on a (possibly protected) card — revoke explicitly, before + // TotalBan's own central "banned" hook, so the ledger's reason reflects the actual + // vahter verdict. TotalBan's hook below then no-ops (grant already cleared). + let! revoked = db.RecordSpamProtectionRevoked(tgMsg.SenderId, "killed") + if revoked then recordSpamProtectionRevoked chatId tgMsg.ChatUsername "killed" let actor = Actor.User {| userId = vahter.Id; username = vahter.Username |} do! this.TotalBan(tgMsg, actor) } @@ -2220,6 +2317,12 @@ type BotService( // 2. Mark as spam (for ML training + karma) do! db.RecordMessageMarkedSpam(chatId, msgId, None) + // 2.5. A vahter spam verdict on a (possibly protected) message revokes protection + // immediately, even though this path doesn't ban — the vahter just judged the user + // spam after all. + let! revoked = db.RecordSpamProtectionRevoked(tgMsg.SenderId, "killed") + if revoked then recordSpamProtectionRevoked chatId chatName "killed" + // 3. Log the action let vahterUsername = vahter.Username |> Option.defaultValue null let logMsg = $"Vahter {prependUsername vahterUsername} ({vahter.Id}) marked message {msgId} in {prependUsername chatName}({chatId}) as SPAM (soft, no ban)\n{tgMsg.Text}\n{msgRefToken chatId msgId}" diff --git a/src/VahterBanBot/DB.fs b/src/VahterBanBot/DB.fs index 1b86536..55393b9 100644 --- a/src/VahterBanBot/DB.fs +++ b/src/VahterBanBot/DB.fs @@ -565,6 +565,51 @@ FROM expanded; member _.RecordReactionTriageNotSpam(userId: int64, until: DateTime, actor: Actor) : Task = recordReactionTriageNotSpamSet userId until actor + // ----------------------------------------------------------------------- + // Public members — Spam protection (temporary post-ham-mark demotion window) + // ----------------------------------------------------------------------- + + /// Looks up the BotAutoDeleted event (if any) on a message's moderation stream, returning + /// the auto-deleted author's userId. Used both to guard a grant on the message actually + /// having been auto-deleted, and (for `/vahter unmarkspam`, which only has chatId/messageId + /// from the #ref token, no TgMessage) to recover the author's userId. + member _.TryGetBotAutoDeletedUserId(chatId: int64, messageId: int64) : Task = + task { + let! events = store.GetEventsForStream($"moderation:{chatId}:{messageId}") + return events |> Array.tryPick (function BotAutoDeleted e -> Some e.userId | _ -> None) + } + + /// Grants (or refreshes) the temporary spam-protection window — always appends, even if a + /// grant is already active, so a repeat ham mark extends `until` and resets the hit budget + /// (User.Fold's SpamProtectionGranted case takes the latest grant unconditionally). + member _.RecordSpamProtectionGranted(userId: int64, until: DateTime, chatId: int64, messageId: int64, vahterId: int64) : Task = + task { + let! _ = appendUserEvents userId (fun (_: User) -> + [ SpamProtectionGranted {| userId = userId; until = until; chatId = chatId; messageId = messageId; vahterId = vahterId |} ]) + return () + } + + /// Records one demotion (ReportPotentialSpam instead of DeleteSpam) against the budget. + member _.RecordSpamProtectionConsumed(userId: int64, chatId: int64, messageId: int64) : Task = + task { + let! _ = appendUserEvents userId (fun (_: User) -> + [ SpamProtectionConsumed {| userId = userId; chatId = chatId; messageId = messageId |} ]) + return () + } + + /// Revokes an active spam-protection grant early. No-op (appends nothing, returns false) if + /// the user has no unexpired grant — this lets call sites that fire unconditionally on every + /// ban/kill (e.g. TotalBan) stay cheap and idempotent without checking first. Returns true + /// iff an event was actually appended, so callers only increment the revoked-count metric on + /// a genuine revocation. + member _.RecordSpamProtectionRevoked(userId: int64, reason: string) : Task = + task { + let! (evts, _) = appendUserEvents userId (fun (state: User) -> + if not (state.HasUnexpiredSpamProtectionGrant (utcNow())) then [] + else [ SpamProtectionRevoked {| userId = userId; reason = reason |} ]) + return not (List.isEmpty evts) + } + // ----------------------------------------------------------------------- // Public members — Profile cache (reaction-spam triage) // ----------------------------------------------------------------------- diff --git a/src/VahterBanBot/Metrics.fs b/src/VahterBanBot/Metrics.fs index b7797ed..c594b32 100644 --- a/src/VahterBanBot/Metrics.fs +++ b/src/VahterBanBot/Metrics.fs @@ -37,6 +37,27 @@ let spamWarningsSentCounter = "Total number of ephemeral spam warnings sent to users after auto-deletion" ) +let spamProtectionsGrantedCounter = + meter.CreateCounter( + "vahter_spam_protections_granted_total", + "grants", + "Total number of temporary spam-protection windows granted after a ham mark on an auto-deleted message" + ) + +let spamProtectionDemotionsCounter = + meter.CreateCounter( + "vahter_spam_protection_demotions_total", + "demotions", + "Total number of would-be auto-deletions demoted to report-only for a protected user" + ) + +let spamProtectionsRevokedCounter = + meter.CreateCounter( + "vahter_spam_protections_revoked_total", + "revocations", + "Total number of spam-protection windows revoked before their natural expiry, tagged by reason" + ) + let spamTextCacheSeedsCounter = meter.CreateCounter( "vahter_spam_text_cache_seeds_total", @@ -98,4 +119,20 @@ let recordDeletedMessagesBatch (chatId: int64) (chatUsername: string) (count: in let recordSpamWarningSent (chatId: int64) (chatUsername: string) = spamWarningsSentCounter.Add(1L, tagsForChat chatId chatUsername) +let recordSpamProtectionGranted (chatId: int64) (chatUsername: string) = + spamProtectionsGrantedCounter.Add(1L, tagsForChat chatId chatUsername) + +let recordSpamProtectionDemotion (chatId: int64) (chatUsername: string) = + spamProtectionDemotionsCounter.Add(1L, tagsForChat chatId chatUsername) + +let tagsForSpamProtectionRevoke (chatId: int64) (chatUsername: string) (reason: string) = + [| + KeyValuePair("chat_id", box chatId) + KeyValuePair("chat_username", box (if isNull chatUsername then "" else chatUsername)) + KeyValuePair("reason", box reason) + |] + +let recordSpamProtectionRevoked (chatId: int64) (chatUsername: string) (reason: string) = + spamProtectionsRevokedCounter.Add(1L, tagsForSpamProtectionRevoke chatId chatUsername reason) + diff --git a/src/VahterBanBot/Program.fs b/src/VahterBanBot/Program.fs index 908a6ab..347d100 100644 --- a/src/VahterBanBot/Program.fs +++ b/src/VahterBanBot/Program.fs @@ -165,7 +165,19 @@ 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.") + // Temporary "vetted" protection after a vahter ham-mark. Off by default — see + // BotConfiguration's doc comment. + SpamProtectionEnabled = getSettingOr "SPAM_PROTECTION_ENABLED" "false" |> bool.Parse + SpamProtectionHours = getSettingOr "SPAM_PROTECTION_HOURS" "48" |> int + SpamProtectionMaxHits = getSettingOr "SPAM_PROTECTION_MAX_HITS" "5" |> int + SpamProtectionNotifyEnabled = getSettingOr "SPAM_PROTECTION_NOTIFY_ENABLED" "false" |> bool.Parse + SpamProtectionNotifyText = + getSettingOr "SPAM_PROTECTION_NOTIFY_TEXT" + ("✅ Модератор проверил ваше удалённое сообщение — это была ошибка фильтра. " + + "Приносим извинения, можете продолжать общение.\n\n" + + "✅ A moderator reviewed your removed message — it was flagged in error. " + + "Sorry about that, feel free to keep chatting.") } let ocrConfigOf (c: BotConfiguration) = { OcrEnabled = c.OcrEnabled diff --git a/src/VahterBanBot/Types.fs b/src/VahterBanBot/Types.fs index 4141f20..9c6cf36 100644 --- a/src/VahterBanBot/Types.fs +++ b/src/VahterBanBot/Types.fs @@ -117,13 +117,31 @@ type UserEvent = /// Reaction-spam triage verdict NOT_SPAM — sets a cooldown so a legit lurker doesn't /// keep re-triggering the pipeline. Set by LLM (autonomous mode) or by a vahter button. | ReactionTriageNotSpamSet of {| userId: int64; until: DateTime; actor: Actor |} + /// Temporary "vetted" protection window granted after a ham mark on one of the user's + /// auto-deleted messages (VahterMarkedAsNotSpam / `/vahter unmarkspam`) — see Bot.fs's + /// MaybeGrantSpamProtection and the SPAM_PROTECTION_* settings. A later grant for the same + /// user REFRESHES the window: User.Fold below always takes the latest `until` and resets the + /// hit budget to 0, it never merges with a still-active prior grant. + | SpamProtectionGranted of {| userId: int64; until: DateTime; chatId: int64; messageId: int64; vahterId: int64 |} + /// One would-be auto-deletion demoted to report-only (ReportPotentialSpam) for a protected + /// user — the budget counter consumed against SpamProtectionMaxHits. Never written alongside + /// a BotAutoDeleted for the same message (demotion means the message was NOT deleted). + | SpamProtectionConsumed of {| userId: int64; chatId: int64; messageId: int64 |} + /// Protection window closed early, before its natural expiry. `reason` is one of + /// "budget" (demotion budget exhausted), "killed" (vahter KILL/soft-spam button), + /// "banned" (manual /ban, /sban, or any other TotalBan of the user), or "markspam" + /// (`/vahter markspam`) — see Bot.fs call sites. A plain time expiry (until <= now) is NOT + /// a revocation and never produces this event; SpamProtectionActive just goes false. + | SpamProtectionRevoked of {| userId: int64; reason: string |} type User = { Id: int64 Banned: (Actor * DateTime) option // (bannedBy, bannedAt) Username: string option ReactionCount: int - NotSpamUntil: DateTime option } // reaction-spam triage cooldown + NotSpamUntil: DateTime option // reaction-spam triage cooldown + SpamProtectionUntil: DateTime option // temporary post-ham-mark protection window + SpamProtectionHits: int } // demotions consumed since the latest grant member this.IsBanned(banExpiryDays: int, now: DateTime) = match this.Banned with | None -> false @@ -133,7 +151,21 @@ type User = match this.NotSpamUntil with | Some until -> until > now | None -> false - static member Zero = { Id = 0L; Banned = None; Username = None; ReactionCount = 0; NotSpamUntil = None } + /// True while a grant exists and hasn't expired yet, regardless of remaining budget — lets + /// callers tell "protection active" (demote) apart from "grant present but budget exhausted" + /// (revoke + normal delete) — see Bot.fs's EnforceOrDemote. + member this.HasUnexpiredSpamProtectionGrant(now: DateTime) = + this.SpamProtectionUntil |> Option.exists (fun until -> until > now) + /// Active protection: unexpired grant, hits under budget, and not currently banned + /// (defensive — banned users never reach this check in practice, since JustMessage + /// short-circuits them before ProcessMessage runs). + member this.SpamProtectionActive(maxHits: int, banExpiryDays: int, now: DateTime) = + not (this.IsBanned(banExpiryDays, now)) + && this.HasUnexpiredSpamProtectionGrant(now) + && this.SpamProtectionHits < maxHits + static member Zero = + { Id = 0L; Banned = None; Username = None; ReactionCount = 0; NotSpamUntil = None + SpamProtectionUntil = None; SpamProtectionHits = 0 } static member Fold (state: User, event: UserEvent) : User = match event with | UsernameChanged e -> { state with Id = e.userId; Username = e.username } @@ -152,6 +184,9 @@ type User = | UserUnbanned e -> { state with Id = e.userId; Banned = None } | UserReactionRecorded e -> { state with Id = e.userId; ReactionCount = state.ReactionCount + e.delta } | ReactionTriageNotSpamSet e -> { state with Id = e.userId; NotSpamUntil = Some e.until } + | SpamProtectionGranted e -> { state with Id = e.userId; SpamProtectionUntil = Some e.until; SpamProtectionHits = 0 } + | SpamProtectionConsumed e -> { state with Id = e.userId; SpamProtectionHits = state.SpamProtectionHits + 1 } + | SpamProtectionRevoked e -> { state with Id = e.userId; SpamProtectionUntil = None; SpamProtectionHits = 0 } static member fromTgUser (user: Funogram.Telegram.Types.User) = { User.Zero with Id = user.Id; Username = user.Username } @@ -518,7 +553,25 @@ 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 + // Temporary "vetted" protection after a vahter ham-mark (see Bot.fs's EnforceOrDemote / + // MaybeGrantSpamProtection). Master flag for BOTH the grant (VahterMarkedAsNotSpam / + // `/vahter unmarkspam`) and the demotion (would-be MlSpam/LlmSpam/ContentFilterSpam + // deletions become ReportPotentialSpam instead) — off by default. + SpamProtectionEnabled: bool + /// Protection window length from grant time (`until = now + hours`). Default 48. + SpamProtectionHours: int + /// Demotions allowed per grant before the window auto-revokes (reason "budget") and lets + /// the next would-be deletion proceed normally. Default 5. + SpamProtectionMaxHits: int + /// When true (default false), send a best-effort ephemeral (Bot API 10.2, same CallIgnore + /// pattern as SpamWarningEnabled) to the vetted user at grant time. Independent of + /// SpamProtectionEnabled's demotion behavior — a grant can be recorded silently. + SpamProtectionNotifyEnabled: bool + /// Fixed bilingual grant-notification text, bot_setting-backed. Deliberately generic — + /// never reveals that enforcement was relaxed or time-boxed, so a wrongly-vetted spammer + /// can't learn a shield exists. + SpamProtectionNotifyText: string } member this.BotActor = Actor.Bot (Some {| botUserId = this.BotUserId; botUsername = this.BotUserName |}) @@ -571,7 +624,8 @@ let snapshotJsonOpts = .WithSkippableOptionFields(SkippableOptionFields.Always) .ToJsonSerializerOptions() -/// Flat snapshot DTOs. Keys MUST match the GENERATED-column expressions in V38__snapshot.sql. +/// Flat snapshot DTOs. Keys MUST match the GENERATED-column expressions in V38__snapshot.sql +/// (and, for the spam-protection fields, V43__spam_protection_snapshot.sql). let userSnapshot (s: User) = // -> snapshot_user.state let bannedByUserId = @@ -582,7 +636,9 @@ let userSnapshot (s: User) = // -> snapshot_user.state bannedAt = s.Banned |> Option.map snd bannedByUserId = bannedByUserId reactionCount = s.ReactionCount - notSpamUntil = s.NotSpamUntil |} + notSpamUntil = s.NotSpamUntil + spamProtectionUntil = s.SpamProtectionUntil + spamProtectionHits = s.SpamProtectionHits |} let messageSnapshot (s: Message) = // -> snapshot_message.message_data {| userId = s.UserId diff --git a/src/vahter-bot/migrations/V43__spam_protection_snapshot.sql b/src/vahter-bot/migrations/V43__spam_protection_snapshot.sql new file mode 100644 index 0000000..ef0fc7d --- /dev/null +++ b/src/vahter-bot/migrations/V43__spam_protection_snapshot.sql @@ -0,0 +1,14 @@ +-- Adds the temporary "vetted" spam-protection window's fields to the snapshot_user read model +-- (see Types.fs's User.SpamProtectionUntil/SpamProtectionHits and Bot.fs's EnforceOrDemote / +-- MaybeGrantSpamProtection). Schema-only — no bot_setting rows are seeded here (SPAM_PROTECTION_* +-- values are hand-run SQL, per AGENTS.md's Settings configuration convention). +-- +-- Same style as V38__snapshot.sql: GENERATED STORED columns projected from the JSONB `state` +-- blob, reusing the IMMUTABLE jsonb_utc_timestamptz wrapper that migration already defined. + +ALTER TABLE snapshot_user + ADD COLUMN spam_protection_until TIMESTAMPTZ GENERATED ALWAYS AS (jsonb_utc_timestamptz(state->>'spamProtectionUntil')) STORED, + ADD COLUMN spam_protection_hits INT GENERATED ALWAYS AS ((state->>'spamProtectionHits')::int) STORED; + +-- Supports "who's currently protected" debug/ops queries without a full table scan. +CREATE INDEX idx_snapshot_user_spam_protection ON snapshot_user (spam_protection_until) WHERE spam_protection_until IS NOT NULL; diff --git a/tests/VahterBanBot.Tests/ContainerTestBase.fs b/tests/VahterBanBot.Tests/ContainerTestBase.fs index c207eb3..a9f59dd 100644 --- a/tests/VahterBanBot.Tests/ContainerTestBase.fs +++ b/tests/VahterBanBot.Tests/ContainerTestBase.fs @@ -49,7 +49,9 @@ type SnapshotUserRow = banned: Nullable banned_at: Nullable banned_by: Nullable - reaction_count: Nullable } + reaction_count: Nullable + spam_protection_until: Nullable + spam_protection_hits: Nullable } [] type SnapshotMessageRow = @@ -774,6 +776,45 @@ WHERE event_type = 'MessageMarkedSpam' return count > 0 } + /// True if a SpamProtectionGranted event exists for this user. + member this.SpamProtectionGranted(userId: int64) = task { + use conn = new NpgsqlConnection(this.DbConnectionString) + //language=postgresql + let sql = + """ +SELECT COUNT(*) FROM event +WHERE stream_id = 'user:' || @userId AND event_type = 'SpamProtectionGranted' + """ + let! count = conn.QuerySingleAsync(sql, {| userId = userId |}) + return count > 0 + } + + /// Number of SpamProtectionConsumed events recorded for this user (the demotion count). + member this.SpamProtectionConsumedCount(userId: int64) = task { + use conn = new NpgsqlConnection(this.DbConnectionString) + //language=postgresql + let sql = + """ +SELECT COUNT(*) FROM event +WHERE stream_id = 'user:' || @userId AND event_type = 'SpamProtectionConsumed' + """ + return! conn.QuerySingleAsync(sql, {| userId = userId |}) + } + + /// `reason` values of every SpamProtectionRevoked event for this user, oldest first. + member this.SpamProtectionRevokedReasons(userId: int64) = task { + use conn = new NpgsqlConnection(this.DbConnectionString) + //language=postgresql + let sql = + """ +SELECT data->>'reason' FROM event +WHERE stream_id = 'user:' || @userId AND event_type = 'SpamProtectionRevoked' +ORDER BY id + """ + let! rows = conn.QueryAsync(sql, {| userId = userId |}) + return Array.ofSeq rows + } + /// Reads the AdminChannelMessage event for (chatId, messageId), if one was persisted. member this.TryGetAdminChannelMessage(chatId: int64, messageId: int64) = task { use conn = new NpgsqlConnection(this.DbConnectionString) @@ -801,7 +842,8 @@ WHERE event_type = 'AdminChannelMessage' //language=postgresql let sql = """ -SELECT user_id, stream_version, username, banned, banned_at, banned_by, reaction_count +SELECT user_id, stream_version, username, banned, banned_at, banned_by, reaction_count, + spam_protection_until, spam_protection_hits FROM snapshot_user WHERE user_id = @userId """ @@ -864,6 +906,18 @@ ON CONFLICT (stream_id, stream_version) DO NOTHING return () } + /// Inserts a SpamProtectionGranted event directly (bypassing the ham-mark flow), so tests + /// can set up a protected user's starting state without orchestrating a full auto-delete + + /// NotASpam-click round trip for every scenario. `version` must be the next free + /// stream_version on `user:{userId}` (1 for a brand-new synthetic test user). + member this.GrantSpamProtection(userId: int64, until: DateTime, chatId: int64, messageId: int64, vahterId: int64, ?version: int) = task { + let v = defaultArg version 1 + do! this.InsertRawEvent( + $"user:{userId}", v, + SpamProtectionGranted {| userId = userId; until = until; chatId = chatId; messageId = messageId; vahterId = vahterId |}, + DateTime.UtcNow) + } + /// Polls `/ready` until the bot reports its ML model is loaded or trained. /// Preload path: ready in <1s. Fresh-train path (first local run): up to ~3 minutes. let private waitForReady (http: HttpClient) (timeout: TimeSpan) : Task = task { diff --git a/tests/VahterBanBot.Tests/SpamProtectionTests.fs b/tests/VahterBanBot.Tests/SpamProtectionTests.fs new file mode 100644 index 0000000..6e1513f --- /dev/null +++ b/tests/VahterBanBot.Tests/SpamProtectionTests.fs @@ -0,0 +1,407 @@ +module VahterBanBot.Tests.SpamProtectionTests + +open System +open System.Text.Json +open System.Threading.Tasks +open VahterBanBot.Tests.ContainerTestBase +open BotTestInfra +open Xunit + +/// Channel posts can be fire-and-forget, so poll for the matching sendMessage call instead of +/// asserting on a single snapshot — same helper as MLBanTests.fs's tryFindChannelPost. +let private tryFindChannelPost (fixture: MlEnabledVahterTestContainers) (channelId: int64) (marker: string) = task { + let mutable found = None + let mutable attempts = 0 + while found.IsNone && attempts < 40 do + let! calls = fixture.GetFakeCalls "sendMessage" + found <- calls |> Array.tryFind (fun c -> + c.Body.Contains $"\"chat_id\":{channelId}" && c.Body.Contains marker) + if found.IsNone then + attempts <- attempts + 1 + do! Task.Delay 250 + return found +} + +let private setSpamProtection (fixture: MlEnabledVahterTestContainers) (enabled: bool) = task { + do! fixture.SetBotSetting("SPAM_PROTECTION_ENABLED", if enabled then "true" else "false") + do! fixture.ReloadSettings() +} + +let private resetSpamProtectionSettings (fixture: MlEnabledVahterTestContainers) = task { + do! fixture.SetBotSetting("SPAM_PROTECTION_ENABLED", "false") + do! fixture.SetBotSetting("SPAM_PROTECTION_MAX_HITS", "5") + do! fixture.SetBotSetting("SPAM_PROTECTION_NOTIFY_ENABLED", "false") + do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", "false") + do! fixture.ReloadSettings() +} + +/// Test item 1 (grant on ham-mark, both entry points) + item 8 (flag off = today's behavior). +type SpamProtectionGrantTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture) = + + [] + let ``Flag ON: NotASpam button click on an auto-deleted message grants spam protection`` () = task { + do! setSpamProtection fixture true + + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222") + let! _ = fixture.SendMessage msgUpdate + let! deleted = fixture.MessageIsAutoDeleted msgUpdate.Message.Value + Assert.True(deleted, "Sanity: message should have been auto-deleted as spam") + + let userId = msgUpdate.Message.Value.From.Value.Id + let! callbackId = fixture.GetCallbackId msgUpdate.Message.Value "NotASpam" + let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = fixture.Vahters[0])) + + let! granted = fixture.SpamProtectionGranted userId + Assert.True(granted, "SpamProtectionGranted event should be recorded") + + let! snap = fixture.TryGetSnapshotUser userId + Assert.True(snap.IsSome) + Assert.True(snap.Value.spam_protection_until.HasValue, "snapshot_user.spam_protection_until should be populated") + Assert.Equal(0, snap.Value.spam_protection_hits.Value) + } + + [] + let ``Flag ON: /vahter unmarkspam on an auto-deleted message grants spam protection`` () = task { + do! setSpamProtection fixture true + // Isolate from any "Deleted spam" post left behind by another Fact sharing this class's + // fixture — tryFindChannelPost below must only ever match THIS test's own message. + do! fixture.ClearFakeCalls() + + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222") + let! _ = fixture.SendMessage msgUpdate + let! deleted = fixture.MessageIsAutoDeleted msgUpdate.Message.Value + Assert.True(deleted, "Sanity: message should have been auto-deleted as spam") + + let chatId = msgUpdate.Message.Value.Chat.Id + let messageId = msgUpdate.Message.Value.MessageId + let userId = msgUpdate.Message.Value.From.Value.Id + + // Forward the AllLogs "Deleted spam" post into the admin channel and reply /vahter + // unmarkspam to it — same recovery flow as MLBanTests' markspam re-mark test. + let! logPost = tryFindChannelPost fixture fixture.AllLogsChannel.Id "Deleted spam" + Assert.True(logPost.IsSome) + let logText = JsonDocument.Parse(logPost.Value.Body).RootElement.GetProperty("text").GetString() + let forwarded = Tg.quickMsg(text = logText, chat = fixture.AdminChannel, from = fixture.Vahters[0]) + let! resp = + Tg.replyMsg(forwarded.Message.Value, "/vahter unmarkspam", fixture.Vahters[0]) + |> fixture.SendMessage + Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode) + + let! ham = fixture.MessageMarkedHam(chatId, messageId) + Assert.True(ham, "Sanity: /vahter unmarkspam should record MessageMarkedHam") + let! granted = fixture.SpamProtectionGranted userId + Assert.True(granted, "SpamProtectionGranted event should be recorded via the /vahter unmarkspam entry point") + } + + [] + let ``Flag OFF (default): behavior identical to today — no grant, repeated spam keeps auto-deleting`` () = task { + do! setSpamProtection fixture false + + let user = Tg.user() + let spamUpdate () = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = user) + + let update1 = spamUpdate () + let! _ = fixture.SendMessage update1 + let! deleted1 = fixture.MessageIsAutoDeleted update1.Message.Value + Assert.True(deleted1, "Sanity: first spam message should be auto-deleted") + + let! callbackId = fixture.GetCallbackId update1.Message.Value "NotASpam" + let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = fixture.Vahters[0])) + + let! granted = fixture.SpamProtectionGranted user.Id + Assert.False(granted, "No SpamProtectionGranted event while the flag is off") + + // Behavior unaffected: the next spam message from the same (now ham-marked-once) user + // still auto-deletes normally — no demotion machinery engaged. + let update2 = spamUpdate () + let! _ = fixture.SendMessage update2 + let! deleted2 = fixture.MessageIsAutoDeleted update2.Message.Value + Assert.True(deleted2, "Flag off: subsequent spam must keep auto-deleting exactly as before this feature") + } + + interface IAsyncDisposable with + member _.DisposeAsync() = ValueTask(resetSpamProtectionSettings fixture :> Task) + + interface IClassFixture + +/// Test item 2: a protected user's would-be ML spam deletion is demoted to report-only. +type SpamProtectionDemotionTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture) = + + [] + let ``Protected user + ML spam verdict is demoted: no delete, no BotAutoDeleted, tagged Potential Spam card, budget consumed`` () = task { + do! setSpamProtection fixture true + + let user = Tg.user() + let spamUpdate () = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = user) + + // First message: not protected yet — deletes normally and seeds the grant via NotASpam. + let update1 = spamUpdate () + let! _ = fixture.SendMessage update1 + let! deleted1 = fixture.MessageIsAutoDeleted update1.Message.Value + Assert.True(deleted1, "Sanity: first spam message should be auto-deleted") + let! callbackId = fixture.GetCallbackId update1.Message.Value "NotASpam" + let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = fixture.Vahters[0])) + let! granted = fixture.SpamProtectionGranted user.Id + Assert.True(granted, "Sanity: protection should now be granted") + + do! fixture.ClearFakeCalls() + + // Second message: now protected — must be demoted, not deleted. + let update2 = spamUpdate () + let! _ = fixture.SendMessage update2 + let! deleted2 = fixture.MessageIsAutoDeleted update2.Message.Value + Assert.False(deleted2, "A protected user's would-be spam deletion must be demoted, not deleted") + + // ASCII-only marker: the 🛡 emoji round-trips through JSON as a \u-escaped surrogate + // pair in the raw HTTP body, so a literal-emoji Contains check on c.Body would never match. + let! card = tryFindChannelPost fixture fixture.PotentialSpamChannel.Id "protected user" + Assert.True(card.IsSome, "Demoted message should post a Potential Spam card tagged with the protected-user marker") + + let! consumed = fixture.SpamProtectionConsumedCount user.Id + Assert.Equal(1, consumed) + } + + interface IAsyncDisposable with + member _.DisposeAsync() = ValueTask(resetSpamProtectionSettings fixture :> Task) + + interface IClassFixture + +/// Test item 3: budget exhaustion revokes protection and lets the exceeding message delete +/// normally, including the existing #395 ephemeral-warning behavior on that deletion. +type SpamProtectionBudgetTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture) = + + [] + let ``Budget exhaustion: the demotion beyond SPAM_PROTECTION_MAX_HITS revokes and deletes normally (warning unaffected)`` () = task { + do! fixture.SetBotSetting("SPAM_PROTECTION_ENABLED", "true") + do! fixture.SetBotSetting("SPAM_PROTECTION_MAX_HITS", "1") + do! fixture.SetBotSetting("SPAM_WARNING_ENABLED", "true") + do! fixture.ReloadSettings() + + let user = Tg.user() + let spamUpdate () = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = user) + + // 1: not protected yet — deletes and grants. + let update1 = spamUpdate () + let! _ = fixture.SendMessage update1 + let! deleted1 = fixture.MessageIsAutoDeleted update1.Message.Value + Assert.True(deleted1, "Sanity: first spam message should be auto-deleted") + let! callbackId = fixture.GetCallbackId update1.Message.Value "NotASpam" + let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = fixture.Vahters[0])) + + // 2: protected, under budget (0 < MaxHits=1) — demoted. + let update2 = spamUpdate () + let! _ = fixture.SendMessage update2 + let! deleted2 = fixture.MessageIsAutoDeleted update2.Message.Value + Assert.False(deleted2, "Sanity: second message should be demoted (under budget)") + let! consumed = fixture.SpamProtectionConsumedCount user.Id + Assert.Equal(1, consumed) + + do! fixture.ClearFakeCalls() + + // 3: budget exhausted (1 >= MaxHits=1) — revoke + normal deletion, warning still fires. + let update3 = spamUpdate () + let! _ = fixture.SendMessage update3 + let! deleted3 = fixture.MessageIsAutoDeleted update3.Message.Value + Assert.True(deleted3, "Budget-exceeding message must delete normally, not demote again") + + let! reasons = fixture.SpamProtectionRevokedReasons user.Id + Assert.Contains("budget", reasons) + + 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) + } + + interface IAsyncDisposable with + member _.DisposeAsync() = ValueTask(resetSpamProtectionSettings fixture :> Task) + + interface IClassFixture + +/// Test item 4: SpamTextCacheHit is a carve-out — a protected user's cache-hit deletion is +/// NEVER demoted, even with protection active. +type SpamProtectionCarveOutTests(fixture: SpamTextCacheEnforceTestContainers, _unused: MlAwaitFixture) = + + [] + let ``Protected user + spam-text cache hit still deletes normally, no demotion`` () = task { + do! fixture.SetBotSetting("SPAM_PROTECTION_ENABLED", "true") + do! fixture.ReloadSettings() + + let spamText = $"click this link right now to claim your huge prize before it expires forever {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") + + // Grant protection to a DIFFERENT user via an unrelated auto-deleted message. + let repeatUser = Tg.user() + let seedSpam = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = repeatUser) + let! _ = fixture.SendMessage seedSpam + let! seedDeleted = fixture.MessageIsAutoDeleted seedSpam.Message.Value + Assert.True(seedDeleted, "Sanity: repeatUser's unrelated message should auto-delete") + let! callbackId = fixture.GetCallbackId seedSpam.Message.Value "NotASpam" + let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = vahter)) + let! granted = fixture.SpamProtectionGranted repeatUser.Id + Assert.True(granted, "Sanity: repeatUser should now be protected") + + 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, "SpamTextCacheHit carve-out: must delete normally even for a protected user") + + let! consumed = fixture.SpamProtectionConsumedCount repeatUser.Id + Assert.Equal(0, consumed) + } + + interface IAsyncDisposable with + member _.DisposeAsync() = ValueTask(task { + do! fixture.SetBotSetting("SPAM_PROTECTION_ENABLED", "false") + do! fixture.ReloadSettings() + } :> Task) + + interface IClassFixture + +/// Test item 5: an expired grant (until in the past) behaves as if never granted. +type SpamProtectionExpiryTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture) = + + [] + let ``Expired grant: would-be spam deletion proceeds normally, no demotion`` () = task { + do! setSpamProtection fixture true + + let user = Tg.user() + let pastUntil = DateTime.UtcNow.AddHours(-1.0) + do! fixture.GrantSpamProtection(user.Id, pastUntil, fixture.ChatsToMonitor[0].Id, 0L, fixture.Vahters[0].Id, version = 1) + + let update = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = user) + let! _ = fixture.SendMessage update + let! deleted = fixture.MessageIsAutoDeleted update.Message.Value + Assert.True(deleted, "An expired grant must not demote — normal deletion proceeds") + + let! consumed = fixture.SpamProtectionConsumedCount user.Id + Assert.Equal(0, consumed) + } + + interface IAsyncDisposable with + member _.DisposeAsync() = ValueTask(resetSpamProtectionSettings fixture :> Task) + + interface IClassFixture + +/// Test item 6: KILL on a protected user's demoted card revokes protection and proceeds with +/// the total ban exactly as today. +type SpamProtectionKillTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture) = + + [] + let ``KILL on a protected user's demoted card revokes protection and bans as usual`` () = task { + do! setSpamProtection fixture true + + let user = Tg.user() + let spamUpdate () = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = user) + + let update1 = spamUpdate () + let! _ = fixture.SendMessage update1 + let! deleted1 = fixture.MessageIsAutoDeleted update1.Message.Value + Assert.True(deleted1, "Sanity: first spam message should be auto-deleted") + let! callbackId = fixture.GetCallbackId update1.Message.Value "NotASpam" + let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = fixture.Vahters[0])) + let! granted = fixture.SpamProtectionGranted user.Id + Assert.True(granted, "Sanity: protection should now be granted") + + let! bannedBefore = fixture.UserBanned user.Id + Assert.False(bannedBefore, "Sanity: user should not be banned yet") + + let update2 = spamUpdate () + let! _ = fixture.SendMessage update2 + let! deleted2 = fixture.MessageIsAutoDeleted update2.Message.Value + Assert.False(deleted2, "Sanity: second message should be demoted, not deleted") + + let! killId = fixture.GetCallbackId update2.Message.Value "Spam" + let! _ = fixture.SendMessage(Tg.callback(string killId, from = fixture.Vahters[0])) + + let! banned = fixture.UserBanned user.Id + Assert.True(banned, "KILL on a demoted card should ban the user exactly as today") + + let! reasons = fixture.SpamProtectionRevokedReasons user.Id + Assert.Contains("killed", reasons) + + let! snap = fixture.TryGetSnapshotUser user.Id + Assert.True(snap.IsSome) + Assert.False(snap.Value.spam_protection_until.HasValue, "snapshot_user.spam_protection_until should be cleared after revocation") + } + + interface IAsyncDisposable with + member _.DisposeAsync() = ValueTask(resetSpamProtectionSettings fixture :> Task) + + interface IClassFixture + +/// Test item 7: the grant-time notification is gated independently by SPAM_PROTECTION_NOTIFY_ENABLED. +type SpamProtectionNotifyTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture) = + + [] + let ``Notify ON: grant sends an ephemeral to the vetted user with the exact text`` () = task { + do! fixture.SetBotSetting("SPAM_PROTECTION_ENABLED", "true") + do! fixture.SetBotSetting("SPAM_PROTECTION_NOTIFY_ENABLED", "true") + do! fixture.ReloadSettings() + + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222") + let! _ = fixture.SendMessage msgUpdate + let! deleted = fixture.MessageIsAutoDeleted msgUpdate.Message.Value + Assert.True(deleted, "Sanity: message should have been auto-deleted as spam") + let userId = msgUpdate.Message.Value.From.Value.Id + + do! fixture.ClearFakeCalls() + let! callbackId = fixture.GetCallbackId msgUpdate.Message.Value "NotASpam" + let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = fixture.Vahters[0])) + + let! calls = fixture.GetFakeCalls "sendMessage" + let notif = + calls + |> Array.tryFind (fun c -> + c.Body.Contains $"\"chat_id\":{fixture.ChatsToMonitor[0].Id}" + && c.Body.Contains $"\"receiver_user_id\":{userId}") + Assert.True(notif.IsSome, "Grant with notify ON should send an ephemeral to the vetted user") + + let expectedText = + "✅ Модератор проверил ваше удалённое сообщение — это была ошибка фильтра. " + + "Приносим извинения, можете продолжать общение.\n\n" + + "✅ A moderator reviewed your removed message — it was flagged in error. " + + "Sorry about that, feel free to keep chatting." + let actualText = JsonDocument.Parse(notif.Value.Body).RootElement.GetProperty("text").GetString() + Assert.Equal(expectedText, actualText) + } + + [] + let ``Notify OFF (default): grant sends no ephemeral to the vetted user`` () = task { + do! fixture.SetBotSetting("SPAM_PROTECTION_ENABLED", "true") + do! fixture.SetBotSetting("SPAM_PROTECTION_NOTIFY_ENABLED", "false") + do! fixture.ReloadSettings() + + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222") + let! _ = fixture.SendMessage msgUpdate + let! deleted = fixture.MessageIsAutoDeleted msgUpdate.Message.Value + Assert.True(deleted, "Sanity: message should have been auto-deleted as spam") + let userId = msgUpdate.Message.Value.From.Value.Id + + do! fixture.ClearFakeCalls() + let! callbackId = fixture.GetCallbackId msgUpdate.Message.Value "NotASpam" + let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = fixture.Vahters[0])) + + let! granted = fixture.SpamProtectionGranted userId + Assert.True(granted, "Sanity: grant should still be recorded with notify off") + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.False( + calls |> Array.exists (fun c -> c.Body.Contains $"\"receiver_user_id\":{userId}"), + "no ephemeral notification must be sent while SPAM_PROTECTION_NOTIFY_ENABLED is false") + } + + interface IAsyncDisposable with + member _.DisposeAsync() = ValueTask(resetSpamProtectionSettings fixture :> Task) + + interface IClassFixture diff --git a/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj b/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj index 0f8318f..06246d0 100644 --- a/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj +++ b/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj @@ -39,6 +39,7 @@ + From 426be11b815e5aa72e7d06cc4b35d765e8bcb814 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Wed, 19 Aug 2026 13:09:47 +0100 Subject: [PATCH 2/2] VahterBanBot: fix cross-test contamination in SpamProtectionCarveOutTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpamTextCacheEnforceTestContainers is an assembly-shared fixture (Program.fs's AssemblyFixture attribute) — one database shared by every test class that takes it, including SpamTextCacheTests' own karma-autoban test. SpamProtectionCarveOutTests was ham-marking the fixed, reused literal "2222222" to grant protection to a test user; DB.fs's GetUserStatsByLastNMessages matches MessageMarkedHam.text GLOBALLY (not scoped to chat/user), so that ham mark silently made every "2222222" message from any user (including SpamTextCacheTests' unrelated autoBanSpammer) count as ham, suppressing the karma "bad" count and preventing autoban — reproduced deterministically both locally and in CI (run 32245300313). Root cause confirmed via the bot container's dumped app.log (test-artifacts/.../ SpamTextCacheEnforceTestContainers/bot.log): a "marked message ... as false-positive (NOT A SPAM)\n2222222" line written by the carve-out test, followed by four "Deleted spam" lines for a different user's "2222222" messages with no "Auto-banned" line and no exception anywhere in the log. Fix: grant protection via direct event injection (GrantSpamProtection) instead of a real ham-mark round trip, so the carve-out test never writes a MessageMarkedHam event for any shared/reused literal. Verified: SpamProtectionCarveOutTests + SpamTextCacheEnforceTests.Startup rehydration together (2/2 passed), full SpamTextCacheEnforceTests (9/9 passed), full SpamProtectionTests (10/10 passed). Claude-Session: https://claude.ai/code/session_01Wi7gKmshkHfVtSB3tA4gmJ Co-authored-by: Claude Fable 5 --- .../VahterBanBot.Tests/SpamProtectionTests.fs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/VahterBanBot.Tests/SpamProtectionTests.fs b/tests/VahterBanBot.Tests/SpamProtectionTests.fs index 6e1513f..5180152 100644 --- a/tests/VahterBanBot.Tests/SpamProtectionTests.fs +++ b/tests/VahterBanBot.Tests/SpamProtectionTests.fs @@ -223,6 +223,17 @@ type SpamProtectionBudgetTests(fixture: MlEnabledVahterTestContainers, _unused: /// Test item 4: SpamTextCacheHit is a carve-out — a protected user's cache-hit deletion is /// NEVER demoted, even with protection active. +/// +/// IMPORTANT: SpamTextCacheEnforceTestContainers is an ASSEMBLY-SHARED fixture (see Program.fs's +/// `[)>]`) — ONE database +/// shared by every test CLASS that takes it as a constructor param (this class, plus +/// SpamTextCacheTests' own SpamTextCacheEnforceTests and SpamWarningTests' SpamWarningCacheHitTests). +/// DB.fs's GetUserStatsByLastNMessages computes `is_ham` by matching MessageMarkedHam.text +/// GLOBALLY (not scoped to chat/user) — ham-marking a fixed, reused literal like "2222222" here +/// would poison SpamTextCacheTests' karma-autoban test for every OTHER user who ever sends that +/// exact text (confirmed root cause of a real CI failure — see PR history). Grant protection via +/// direct event injection (GrantSpamProtection) instead of a real ham-mark round trip, so this +/// test never writes a MessageMarkedHam event for any shared/reused text at all. type SpamProtectionCarveOutTests(fixture: SpamTextCacheEnforceTestContainers, _unused: MlAwaitFixture) = [] @@ -238,14 +249,11 @@ type SpamProtectionCarveOutTests(fixture: SpamTextCacheEnforceTestContainers, _u let! seedUserBanned = fixture.UserBanned originalMsg.Message.Value.From.Value.Id Assert.True(seedUserBanned, "Sanity: original spammer should be banned, seeding the cache") - // Grant protection to a DIFFERENT user via an unrelated auto-deleted message. + // Grant protection to a DIFFERENT user directly (no real message, no ham mark) — + // see the type doc comment for why this must never round-trip through a shared literal. let repeatUser = Tg.user() - let seedSpam = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "2222222", from = repeatUser) - let! _ = fixture.SendMessage seedSpam - let! seedDeleted = fixture.MessageIsAutoDeleted seedSpam.Message.Value - Assert.True(seedDeleted, "Sanity: repeatUser's unrelated message should auto-delete") - let! callbackId = fixture.GetCallbackId seedSpam.Message.Value "NotASpam" - let! _ = fixture.SendMessage(Tg.callback(string callbackId, from = vahter)) + let until = DateTime.UtcNow.AddHours(48.0) + do! fixture.GrantSpamProtection(repeatUser.Id, until, fixture.ChatsToMonitor[0].Id, 0L, vahter.Id, version = 1) let! granted = fixture.SpamProtectionGranted repeatUser.Id Assert.True(granted, "Sanity: repeatUser should now be protected")