Skip to content

Commit 35c0b7d

Browse files
authored
Add soft spam button (SPAM) for potential spam messages (#103)
1 parent e3cc01e commit 35c0b7d

File tree

5 files changed

+213
-29
lines changed

5 files changed

+213
-29
lines changed

src/VahterBanBot.Tests/ContainerTestBase.fs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,25 @@ WHERE data ->> 'Case' = @caseName
316316
let! result = conn.QueryAsync<int>(sql, {| userId = userId |})
317317
return result |> Seq.tryHead |> Option.defaultValue 0
318318
}
319+
320+
member _.IsMessageFalseNegative(msg: Message) = task {
321+
use conn = new NpgsqlConnection(publicConnectionString)
322+
//language=postgresql
323+
let sql = "SELECT COUNT(*) FROM false_negative_messages WHERE chat_id = @chatId AND message_id = @messageId"
324+
let! count = conn.QuerySingleAsync<int>(sql, {| chatId = msg.Chat.Id; messageId = msg.MessageId |})
325+
return count > 0
326+
}
327+
328+
/// Checks if message was deleted (exists in fake API deleted list)
329+
member _.MessageWasDeleted(msg: Message) = task {
330+
use conn = new NpgsqlConnection(publicConnectionString)
331+
// Check if message is in banned_by_bot (for auto-deleted) or was manually deleted
332+
// For soft spam, we check the fake API's deleted messages
333+
//language=postgresql
334+
let sql = "SELECT COUNT(*) FROM banned_by_bot WHERE banned_in_chat_id = @chatId AND message_id = @messageId"
335+
let! count = conn.QuerySingleAsync<int>(sql, {| chatId = msg.Chat.Id; messageId = msg.MessageId |})
336+
return count > 0
337+
}
319338

320339
type MlEnabledVahterTestContainers() =
321340
inherit VahterTestContainers(mlEnabled = true)

src/VahterBanBot.Tests/MLBanTests.fs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,90 @@ type MLBanTests(fixture: MlEnabledVahterTestContainers, _unused: MlAwaitFixture)
405405
let! dbMsg = fixture.TryGetDbMessage msgUpdate.Message
406406
Assert.Equal("Hello!\nb", dbMsg.Value.text)
407407
}
408+
409+
[<Fact>]
410+
let ``MarkAsSpam (soft spam) button does NOT ban user`` () = task {
411+
// This test verifies the critical behavior that MarkAsSpam (soft spam)
412+
// deletes the message and marks it as spam for ML, but does NOT ban the user
413+
let user = Tg.user()
414+
let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "77", from = user)
415+
let! _ = fixture.SendMessage msgUpdate
416+
417+
// User should NOT be banned initially
418+
let! userBannedBefore = fixture.UserBanned user.Id
419+
Assert.False(userBannedBefore, "User should not be banned initially")
420+
421+
// Click MarkAsSpam button (soft spam) - uses the new third button
422+
let! callbackId = fixture.GetCallbackId msgUpdate.Message "MarkAsSpam"
423+
let msgCallback = Tg.callback(string callbackId, from = fixture.Vahters[0])
424+
let! _ = fixture.SendMessage msgCallback
425+
426+
// CRITICAL: User should still NOT be banned after MarkAsSpam button
427+
// This is the main assertion - soft spam should NOT ban the user
428+
let! userBannedAfter = fixture.UserBanned user.Id
429+
Assert.False(userBannedAfter, "User should NOT be banned after MarkAsSpam - this is soft delete only!")
430+
431+
// Message should be marked as false negative (spam for ML training)
432+
let! isFalseNegative = fixture.IsMessageFalseNegative msgUpdate.Message
433+
Assert.True(isFalseNegative, "Message should be marked as false negative for ML")
434+
}
435+
436+
[<Fact>]
437+
let ``Only vahter can click MarkAsSpam button`` () = task {
438+
// Similar to "Only vahter can press THE BUTTON(s)" test
439+
// Verifies non-vahter clicking MarkAsSpam has no effect
440+
let user = Tg.user()
441+
let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "77", from = user)
442+
let! _ = fixture.SendMessage msgUpdate
443+
444+
// Try to click MarkAsSpam as regular user (not vahter)
445+
let! callbackId = fixture.GetCallbackId msgUpdate.Message "MarkAsSpam"
446+
let msgCallback = Tg.callback(string callbackId, from = user) // regular user, not vahter
447+
let! _ = fixture.SendMessage msgCallback
448+
449+
// Message should NOT be marked as false negative (action was rejected)
450+
let! isFalseNegative = fixture.IsMessageFalseNegative msgUpdate.Message
451+
Assert.False(isFalseNegative, "Non-vahter should not be able to mark message as spam")
452+
}
453+
454+
[<Fact>]
455+
let ``User will be autobanned after consecutive MarkAsSpam clicks`` () = task {
456+
// Tests that karma system works with MarkAsSpam
457+
// After enough soft spam marks, user gets auto-banned
458+
// ML_SPAM_AUTOBAN_SCORE_THRESHOLD is -4.0
459+
// socialScore <= threshold triggers ban, so:
460+
// - After 1st: score=-1, -1 > -4 → no ban
461+
// - After 2nd: score=-2, -2 > -4 → no ban
462+
// - After 3rd: score=-3, -3 > -4 → no ban
463+
// - After 4th: score=-4, -4 <= -4 → BAN!
464+
let user = Tg.user()
465+
466+
// First 3 messages should NOT trigger ban
467+
for i in 1..3 do
468+
let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "77", from = user)
469+
let! _ = fixture.SendMessage msgUpdate
470+
471+
// Click MarkAsSpam (soft spam)
472+
let! callbackId = fixture.GetCallbackId msgUpdate.Message "MarkAsSpam"
473+
let msgCallback = Tg.callback(string callbackId, from = fixture.Vahters[0])
474+
let! _ = fixture.SendMessage msgCallback
475+
476+
// User should not be banned yet (score is -1, -2, -3)
477+
let! userBanned = fixture.UserBanned user.Id
478+
Assert.False(userBanned, $"User should not be banned after {i} soft spam marks (score={-i})")
479+
480+
// 4th soft spam should trigger auto-ban (score becomes -4 which is <= -4.0 threshold)
481+
let finalMsg = Tg.quickMsg(chat = fixture.ChatsToMonitor[0], text = "77", from = user)
482+
let! _ = fixture.SendMessage finalMsg
483+
484+
let! callbackId = fixture.GetCallbackId finalMsg.Message "MarkAsSpam"
485+
let msgCallback = Tg.callback(string callbackId, from = fixture.Vahters[0])
486+
let! _ = fixture.SendMessage msgCallback
487+
488+
// Now user should be auto-banned due to low karma (score=-4 <= threshold=-4)
489+
let! userBanned = fixture.UserBanned user.Id
490+
Assert.True(userBanned, "User should be auto-banned after reaching karma threshold via soft spam")
491+
}
408492

409493
interface IAssemblyFixture<MlEnabledVahterTestContainers>
410494
interface IClassFixture<MlAwaitFixture>

src/VahterBanBot/Bot.fs

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -497,11 +497,13 @@ let killSpammerAutomated
497497
InlineKeyboardButton.WithCallbackData("✅ NOT a spam", string callback.id)
498498
]
499499
else
500-
// Potential spam → two callbacks
500+
// Potential spam → three callbacks
501501
let! killCallback = DB.newCallbackPending (CallbackMessage.Spam { message = message }) message.From.Id channelId
502+
let! softSpamCallback = DB.newCallbackPending (CallbackMessage.MarkAsSpam { message = message }) message.From.Id channelId
502503
let! notSpamCallback = DB.newCallbackPending (CallbackMessage.NotASpam { message = message }) message.From.Id channelId
503-
return [killCallback.id; notSpamCallback.id], InlineKeyboardMarkup [|
504+
return [killCallback.id; softSpamCallback.id; notSpamCallback.id], InlineKeyboardMarkup [|
504505
InlineKeyboardButton.WithCallbackData("🚫 KILL", string killCallback.id);
506+
InlineKeyboardButton.WithCallbackData("⚠️ SPAM", string softSpamCallback.id);
505507
InlineKeyboardButton.WithCallbackData("✅ NOT SPAM", string notSpamCallback.id)
506508
|]
507509
}
@@ -526,31 +528,45 @@ let killSpammerAutomated
526528
logger.LogInformation logMsg
527529
}
528530

529-
let autoBan
531+
/// Checks user's social score and triggers auto-ban if below threshold
532+
/// Returns true if user was auto-banned, false otherwise
533+
let checkAndAutoBan
530534
(botUser: DbUser)
531535
(botClient: ITelegramBotClient)
532536
(botConfig: BotConfiguration)
533537
(message: Message)
534538
(logger: ILogger) = task {
535-
use banOnReplyActivity = botActivity.StartActivity("autoBan")
536-
%banOnReplyActivity
537-
.SetTag("spammerId", message.From.Id)
538-
.SetTag("spammerUsername", message.From.Username)
539-
540-
let! userStats = DB.getUserStatsByLastNMessages botConfig.MlSpamAutobanCheckLastMsgCount message.From.Id
541-
let socialScore = userStats.good - userStats.bad
542-
543-
%banOnReplyActivity.SetTag("socialScore", socialScore)
544-
545-
if double socialScore <= botConfig.MlSpamAutobanScoreThreshold then
546-
// ban user in all monitored chats
547-
do! totalBan botClient botConfig message botUser logger
548-
let msg = $"Auto-banned user {prependUsername message.From.Username} ({message.From.Id}) due to the low social score {socialScore}"
549-
logger.LogInformation msg
550-
do! botClient.SendTextMessageAsync(
551-
chatId = ChatId(botConfig.AllLogsChannelId),
552-
text = msg
553-
) |> taskIgnore
539+
if not botConfig.MlSpamAutobanEnabled then
540+
return false
541+
else
542+
use banOnReplyActivity = botActivity.StartActivity("checkAndAutoBan")
543+
%banOnReplyActivity
544+
.SetTag("spammerId", message.From.Id)
545+
.SetTag("spammerUsername", message.From.Username)
546+
547+
let! userStats = DB.getUserStatsByLastNMessages botConfig.MlSpamAutobanCheckLastMsgCount message.From.Id
548+
let socialScore = userStats.good - userStats.bad
549+
550+
%banOnReplyActivity.SetTag("socialScore", socialScore)
551+
552+
if double socialScore <= botConfig.MlSpamAutobanScoreThreshold then
553+
// ban user in all monitored chats
554+
do! totalBan botClient botConfig message botUser logger
555+
let msg = $"Auto-banned user {prependUsername message.From.Username} ({message.From.Id}) due to the low social score {socialScore}"
556+
logger.LogInformation msg
557+
do! botClient.SendTextMessageAsync(
558+
chatId = ChatId(botConfig.AllLogsChannelId),
559+
text = msg
560+
) |> taskIgnore
561+
return true
562+
else
563+
return false
564+
}
565+
566+
/// Wrapper for backward compatibility - calls checkAndAutoBan and ignores result
567+
let autoBan botUser botClient botConfig message logger = task {
568+
let! _ = checkAndAutoBan botUser botClient botConfig message logger
569+
return ()
554570
}
555571

556572
let totalBanByReaction
@@ -744,10 +760,8 @@ let justMessage
744760
if prediction.Score >= botConfig.MlSpamThreshold then
745761
// delete message
746762
do! killSpammerAutomated botClient botConfig message logger botConfig.MlSpamDeletionEnabled prediction.Score
747-
748-
if botConfig.MlSpamAutobanEnabled then
749-
// trigger auto-ban check
750-
do! autoBan botUser botClient botConfig message logger
763+
// trigger auto-ban check (checkAndAutoBan handles MlSpamAutobanEnabled internally)
764+
do! autoBan botUser botClient botConfig message logger
751765
elif prediction.Score >= botConfig.MlWarningThreshold then
752766
// just warn
753767
do! killSpammerAutomated botClient botConfig message logger false prediction.Score
@@ -985,8 +999,50 @@ let vahterMarkedAsSpam
985999
logger
9861000
}
9871001

1002+
/// Soft spam handler - deletes message and marks as spam for ML, but does NOT ban user
1003+
/// User may get auto-banned if karma threshold is reached
1004+
let vahterSoftSpam
1005+
(botUser: DbUser)
1006+
(botClient: ITelegramBotClient)
1007+
(botConfig: BotConfiguration)
1008+
(logger: ILogger)
1009+
(vahter: DbUser)
1010+
(message: MessageWrapper) = task {
1011+
let msg = message.message
1012+
let msgId = msg.MessageId
1013+
let chatId = msg.Chat.Id
1014+
let chatName = msg.Chat.Username
1015+
use _ =
1016+
botActivity
1017+
.StartActivity("vahterSoftSpam")
1018+
.SetTag("messageId", msgId)
1019+
.SetTag("chatId", chatId)
1020+
1021+
// 1. Delete the message from original chat
1022+
recordDeletedMessage chatId chatName "softSpam"
1023+
do! botClient.DeleteMessageAsync(ChatId(chatId), msgId)
1024+
|> safeTaskAwait (fun e -> logger.LogWarning($"Failed to delete message {msgId} from chat {chatId}", e))
1025+
1026+
// 2. Mark as false negative (for ML training + karma)
1027+
do! DB.markMessageAsFalseNegative chatId msgId
1028+
1029+
// 3. Log the action
1030+
let vahterUsername = vahter.username |> Option.defaultValue null
1031+
let logMsg = $"Vahter {prependUsername vahterUsername} ({vahter.id}) marked message {msgId} in {prependUsername chatName}({chatId}) as SPAM (soft, no ban)\n{msg.TextOrCaption}"
1032+
do! botClient.SendTextMessageAsync(
1033+
chatId = ChatId(botConfig.AllLogsChannelId),
1034+
text = logMsg
1035+
) |> taskIgnore
1036+
logger.LogInformation logMsg
1037+
1038+
// 4. Check auto-ban using shared logic (karma system)
1039+
let! _ = checkAndAutoBan botUser botClient botConfig msg logger
1040+
()
1041+
}
1042+
9881043
// just an aux function to reduce indentation in onCallback and prevent FS3511
9891044
let onCallbackAux
1045+
(botUser: DbUser)
9901046
(botClient: ITelegramBotClient)
9911047
(botConfig: BotConfiguration)
9921048
(logger: ILogger)
@@ -995,12 +1051,13 @@ let onCallbackAux
9951051
(dbCallback: DbCallback)
9961052
(callbackQuery: CallbackQuery)= task {
9971053
let callback = dbCallback.data
998-
let msg = match callback with NotASpam m | Spam m -> m
1054+
let msg = match callback with NotASpam m | Spam m | MarkAsSpam m -> m
9991055

10001056
// Determine action type based on callback data and channel
10011057
let actionType =
10021058
match callback with
10031059
| Spam _ -> "potential_kill"
1060+
| MarkAsSpam _ -> "potential_soft_spam"
10041061
| NotASpam _ ->
10051062
if dbCallback.action_channel_id = Some botConfig.DetectedSpamChannelId
10061063
then "detected_not_spam"
@@ -1021,6 +1078,9 @@ let onCallbackAux
10211078
| Spam msg ->
10221079
%onCallbackActivity.SetTag("type", "Spam")
10231080
do! vahterMarkedAsSpam botClient botConfig logger vahter msg
1081+
| MarkAsSpam msg ->
1082+
%onCallbackActivity.SetTag("type", "MarkAsSpam")
1083+
do! vahterSoftSpam botUser botClient botConfig logger vahter msg
10241084

10251085
do! botClient.AnswerCallbackQueryAsync(callbackQuery.Id, "Done! +1 🎯")
10261086
else
@@ -1044,6 +1104,7 @@ let onCallbackAux
10441104
}
10451105

10461106
let onCallback
1107+
(botUser: DbUser)
10471108
(botClient: ITelegramBotClient)
10481109
(botConfig: BotConfiguration)
10491110
(logger: ILogger)
@@ -1078,6 +1139,7 @@ let onCallback
10781139
do! botClient.AnswerCallbackQueryAsync(callbackQuery.Id, "Not authorized")
10791140
else
10801141
do! onCallbackAux
1142+
botUser
10811143
botClient
10821144
botConfig
10831145
logger
@@ -1165,7 +1227,7 @@ let onUpdate
11651227
(update: Update) = task {
11661228
use _ = botActivity.StartActivity("onUpdate")
11671229
if update.CallbackQuery <> null then
1168-
do! onCallback botClient botConfig logger update.CallbackQuery
1230+
do! onCallback botUser botClient botConfig logger update.CallbackQuery
11691231
elif update.MessageReaction <> null then
11701232
do! onMessageReaction botClient botConfig logger update.MessageReaction
11711233
elif update.EditedOrMessage <> null then

src/VahterBanBot/DB.fs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,24 @@ ON CONFLICT DO NOTHING;
297297
return! conn.ExecuteAsync(sql, message)
298298
}
299299

300+
/// Marks a message as false negative (spam that was not auto-detected)
301+
/// Used for soft spam marking - counts toward karma but doesn't ban
302+
let markMessageAsFalseNegative (chatId: int64) (messageId: int): Task =
303+
task {
304+
use conn = new NpgsqlConnection(connString)
305+
306+
//language=postgresql
307+
let sql =
308+
"""
309+
INSERT INTO false_negative_messages (chat_id, message_id)
310+
VALUES (@chatId, @messageId)
311+
ON CONFLICT DO NOTHING;
312+
"""
313+
314+
let! _ = conn.ExecuteAsync(sql, {| chatId = chatId; messageId = messageId |})
315+
return ()
316+
}
317+
300318
/// Creates a callback without action_message_id (first phase of two-phase insert)
301319
let newCallbackPending (data: CallbackMessage) (targetUserId: int64) (channelId: int64): Task<DbCallback> =
302320
task {

src/VahterBanBot/Types.fs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ type MessageWrapper= { message: Message }
153153
// as it is used to (de)serialize the button callback data
154154
type CallbackMessage =
155155
| NotASpam of MessageWrapper
156-
| Spam of MessageWrapper
156+
| Spam of MessageWrapper // hard kill - delete all messages and ban user in all chats
157+
| MarkAsSpam of MessageWrapper // soft spam - delete message but no ban
157158

158159
[<CLIMutable>]
159160
type DbCallback =

0 commit comments

Comments
 (0)